···11+(*
22+ * ISC License
33+ *
44+ * Copyright (c) 2024 Anil Madhavapeddy <anil@recoil.org>
55+ *
66+ * Permission to use, copy, modify, and distribute this software for any
77+ * purpose with or without fee is hereby granted, provided that the above
88+ * copyright notice and this permission notice appear in all copies.
99+ *
1010+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1111+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1212+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1313+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1414+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1515+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1616+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1717+ *
1818+ *)
···11+module SC = Srcsetter_cmd
22+33+let min_interval = Some (Mtime.Span.of_uint64_ns 1000L)
44+55+let stage1 { SC.img_exts; src_dir; _ } =
66+ let filter f = List.exists (Filename.check_suffix ("." ^ f)) img_exts in
77+ let fs = SC.file_seq ~filter src_dir in
88+ let total = Seq.length fs in
99+ Format.printf "[1/3] Scanned %d images from %a.\n%!" total Eio.Path.pp src_dir;
1010+ fs
1111+1212+let stage2 ({ SC.max_fibers; dst_dir; _ } as cfg) fs =
1313+ let display =
1414+ Progress.Display.start
1515+ ~config:(Progress.Config.v ~persistent:false ~min_interval ())
1616+ (SC.main_bar_heading "[2/3] Processing images..." (Seq.length fs))
1717+ in
1818+ let [ _; main_rep ] = Progress.Display.reporters display in
1919+ let ents = ref [] in
2020+ SC.iter_seq_p ~max_fibers
2121+ (fun src ->
2222+ let ent = SC.process_file cfg (display, main_rep) src in
2323+ ents := ent :: !ents)
2424+ fs;
2525+ Progress.Display.finalise display;
2626+ Format.printf "[2/3] Processed %d images to %a.\n%!" (List.length !ents)
2727+ Eio.Path.pp dst_dir;
2828+ !ents
2929+3030+let stage3 ({ SC.dst_dir; max_fibers; _ } as cfg) ents =
3131+ let ents_seq = List.to_seq ents in
3232+ let oents = ref [] in
3333+ let display =
3434+ Progress.Display.start
3535+ ~config:(Progress.Config.v ~persistent:false ~min_interval ())
3636+ (SC.main_bar_heading "[3/3] Verifying images..." (List.length ents))
3737+ in
3838+ let [ _; rep ] = Progress.Display.reporters display in
3939+ SC.iter_seq_p ~max_fibers
4040+ (fun ent ->
4141+ let w, h = SC.dims cfg Eio.Path.(dst_dir / Srcsetter.name ent) in
4242+ let variants =
4343+ Srcsetter.MS.bindings ent.variants
4444+ |> List.map (fun (k, _) -> (k, SC.dims cfg Eio.Path.(dst_dir / k)))
4545+ |> Srcsetter.MS.of_list
4646+ in
4747+ rep 1;
4848+ oents := { ent with Srcsetter.dims = (w, h); variants } :: !oents)
4949+ ents_seq;
5050+ Progress.Display.finalise display;
5151+ Printf.printf "[3/3] Verified %d generated image sizes.\n%!"
5252+ (List.length ents);
5353+ !oents
5454+5555+let _ =
5656+ (* TODO cmdliner *)
5757+ Eio_main.run @@ fun env ->
5858+ Eio.Switch.run @@ fun _ ->
5959+ let path_env p =
6060+ if String.starts_with ~prefix:"/" p then Eio.(Path.(Stdenv.fs env / p))
6161+ else Eio.(Path.(Stdenv.cwd env / p))
6262+ in
6363+ let src_dir = path_env "bushel/images" in
6464+ let dst_dir = path_env "site/images" in
6565+ let proc_mgr = Eio.Stdenv.process_mgr env in
6666+ let idx_file = "index.json" in
6767+ let img_widths =
6868+ [ 320; 480; 640; 768; 1024; 1280; 1440; 1600; 1920; 2560; 3840 ]
6969+ in
7070+ let img_exts = [ "png"; "webp"; "jpeg"; "jpg"; "bmp"; "heic"; "gif" ] in
7171+ let img_widths = List.sort (fun a b -> compare b a) img_widths in
7272+ let max_fibers = 8 in
7373+ let cfg =
7474+ {
7575+ Srcsetter_cmd.dummy = false;
7676+ preserve = true;
7777+ proc_mgr;
7878+ src_dir;
7979+ dst_dir;
8080+ idx_file;
8181+ img_widths;
8282+ img_exts;
8383+ max_fibers;
8484+ }
8585+ in
8686+ let fs = stage1 cfg in
8787+ let ents = stage2 cfg fs in
8888+ let oents = stage3 cfg ents in
8989+ let j = Srcsetter.list_to_json oents |> Result.get_ok in
9090+ let idx = Eio.Path.(dst_dir / idx_file) in
9191+ Eio.Path.save ~append:false ~create:(`Or_truncate 0o644) idx j
+21
dune-project
···11+(lang dune 3.17)
22+(name srcsetter)
33+44+(generate_opam_files true)
55+66+(source (github avsm/srcsetter))
77+(license ISC)
88+(authors "Anil Madhavapeddy")
99+(maintainers "anil@recoil.org")
1010+1111+(package
1212+ (name srcsetter)
1313+ (description "Srcsetter is a library that allows for handling a directory of responsive images suitable for embedding as `<img srcset` tags in a website. It uses the ImageMagick CLI tool to handle the actual processing of images, and the `srcsetter-cmd` package to generate the input to this library.")
1414+ (synopsis "Image srcset library for webp images")
1515+ (depends jsont bytesrw pro))
1616+1717+(package
1818+ (name srcsetter-cmd)
1919+ (synopsis "Image processing tool to generate responsive images")
2020+ (description "Srcsetter is a CLI tool that processes a directory of images and outputs a directory of responsive images suitable for embedding as `<img srcset` tags in a website. It uses the ImageMagick CLI tool to handle the actual processing of images.")
2121+ (depends srcsetter fpath progress eio))
···11+module MS = Map.Make (String)
22+33+type t = {
44+ name : string;
55+ slug : string;
66+ origin : string;
77+ dims : int * int;
88+ variants : (int * int) MS.t;
99+}
1010+1111+let v name slug origin variants dims = { name; slug; origin; variants; dims }
1212+let origin { origin; _ } = origin
1313+let slug { slug; _ } = slug
1414+let name { name; _ } = name
1515+let dims { dims; _ } = dims
1616+let variants { variants; _ } = variants
1717+1818+let dims_json_t =
1919+ let open Jsont in
2020+ let dec x y = (x, y) in
2121+ let enc (w, h) = function 0 -> w | _ -> h in
2222+ t2 ~dec ~enc uint16
2323+2424+let json_t =
2525+ let open Jsont in
2626+ let open Jsont.Object in
2727+ map ~kind:"Entry" v
2828+ |> mem "name" string ~enc:name
2929+ |> mem "slug" string ~enc:slug
3030+ |> mem "origin" string ~enc:origin
3131+ |> mem "variants" (as_string_map dims_json_t) ~enc:variants
3232+ |> mem "dims" dims_json_t ~enc:dims
3333+ |> finish
3434+3535+let list = Jsont.list json_t
3636+let list_to_json es = Jsont_bytesrw.encode_string list ~format:Jsont.Indent es
3737+let list_of_json = Jsont_bytesrw.decode_string list
+19
lib/srcsetter.mli
···11+module MS : Map.S with type key = string
22+33+type t = {
44+ name : string;
55+ slug : string;
66+ origin : string;
77+ dims : int * int;
88+ variants : (int * int) MS.t;
99+}
1010+1111+val v : string -> string -> string -> (int * int) MS.t -> int * int -> t
1212+val origin : t -> string
1313+val name : t -> string
1414+val dims : t -> int * int
1515+val variants : t -> (int * int) MS.t
1616+val list_to_json : t list -> (string, string) result
1717+val list_of_json : string -> (t list, string) result
1818+val json_t : t Jsont.t
1919+val list : t list Jsont.t
+238
lib/srcsetter_cmd.ml
···11+open Eio
22+33+type ('a, 'b) config = {
44+ dummy : bool;
55+ preserve : bool;
66+ proc_mgr : 'a Eio.Process.mgr;
77+ src_dir : 'b Path.t;
88+ dst_dir : 'b Path.t;
99+ img_widths : int list;
1010+ img_exts : string list;
1111+ idx_file : string;
1212+ max_fibers : int;
1313+}
1414+1515+let rec file_seq ~filter path =
1616+ Path.with_open_dir path Path.read_dir
1717+ |> List.fold_left
1818+ (fun (dirs, files) f ->
1919+ let fp = Path.(path / f) in
2020+ match Path.kind ~follow:false fp with
2121+ | `Regular_file when filter f -> (dirs, fp :: files)
2222+ | `Directory -> (f :: dirs, files)
2323+ | _ -> (dirs, files))
2424+ ([], [])
2525+ |> fun (dirs, files) ->
2626+ Seq.append (List.to_seq files)
2727+ (Seq.flat_map
2828+ (fun f -> file_seq ~filter Path.(path / f))
2929+ (List.to_seq dirs))
3030+3131+let iter_seq_p ?max_fibers fn seq =
3232+ Eio.Switch.run ~name:"iter_seq_p" @@ fun sw ->
3333+ match max_fibers with
3434+ | None -> Seq.iter (fun v -> Fiber.fork ~sw @@ fun () -> fn v) seq
3535+ | Some mf when mf <= 0 -> invalid_arg "iter_seq_p max_fibers"
3636+ | Some mf ->
3737+ let s = Semaphore.make mf in
3838+ Seq.iter
3939+ (fun v ->
4040+ Semaphore.acquire s;
4141+ Fiber.fork ~sw @@ fun () ->
4242+ Fun.protect ~finally:(fun () -> Semaphore.release s) @@ fun () -> fn v)
4343+ seq
4444+4545+let relativize_path dir path =
4646+ let dir = Path.native_exn dir in
4747+ let path = Path.native_exn path in
4848+ match Fpath.(rem_prefix (v dir) (v path)) with
4949+ | None -> failwith "bad path prefix"
5050+ | Some v -> Fpath.to_string v
5151+5252+let dims { proc_mgr; _ } fl =
5353+ let fl = Path.native_exn fl in
5454+ let args = [ "identify"; "-ping"; "-format"; "%w %h"; fl ] in
5555+ let l = Process.parse_out proc_mgr Buf_read.take_all args in
5656+ Scanf.sscanf l "%d %d" (fun w h -> (w, h))
5757+5858+let run { dummy; proc_mgr; _ } args =
5959+ if not dummy then Process.run proc_mgr args
6060+6161+let convert ({ src_dir; dst_dir; dummy; _ } as cfg) (src, dst, size) =
6262+ if dummy then () (* TODO log skip *)
6363+ else
6464+ let dir =
6565+ if Filename.dirname dst = "." then dst_dir
6666+ else Path.(dst_dir / Filename.dirname dst)
6767+ in
6868+ Path.(mkdirs ~exists_ok:true ~perm:0o755 dir);
6969+ let src = Path.(native_exn (src_dir / src)) in
7070+ let dst = Path.(native_exn (dst_dir / dst)) in
7171+ let sz = Printf.sprintf "%dx" size in
7272+ let args =
7373+ [
7474+ "magick";
7575+ src;
7676+ "-auto-orient";
7777+ "-thumbnail";
7878+ sz;
7979+ "-quality";
8080+ "100";
8181+ "-gravity";
8282+ "center";
8383+ "-extent";
8484+ sz;
8585+ dst;
8686+ ]
8787+ in
8888+ run cfg args
8989+9090+let convert_pdf cfg ~size ~dst ~src =
9191+ let src = Path.native_exn src in
9292+ let dst = Path.native_exn dst in
9393+ let sz = Printf.sprintf "%sx" size in
9494+ let args =
9595+ [
9696+ "magick";
9797+ "-density";
9898+ "300";
9999+ "-quality";
100100+ "100";
101101+ src ^ "[0]";
102102+ "-gravity";
103103+ "North";
104104+ "-crop";
105105+ "100%x50%+0+0";
106106+ "-resize";
107107+ sz;
108108+ dst;
109109+ ]
110110+ in
111111+ run cfg args
112112+113113+let needed_sizes ~img_widths ~w = List.filter (fun tw -> tw <= w) img_widths
114114+115115+let translate { src_dir; dst_dir; preserve; _ } ?w src =
116116+ let src_file = relativize_path src_dir src in
117117+ let dst_file =
118118+ Printf.sprintf "%s%s.webp"
119119+ (Filename.chop_extension src_file)
120120+ (match w with None -> "" | Some w -> "." ^ string_of_int w)
121121+ in
122122+ let dst = Path.(dst_dir / dst_file) in
123123+ match (preserve, Path.is_file dst) with
124124+ | true, true -> (src_file, dst_file, w, false)
125125+ | _, false -> (src_file, dst_file, w, true)
126126+ | false, true -> (src_file, dst_file, w, true)
127127+128128+let calc_needed { src_dir; dst_dir; preserve; _ } ~img_widths ~w src =
129129+ let ent_of_dst fname tw =
130130+ let dst = Path.(dst_dir / fname) in
131131+ let ent = (src, dst, tw) in
132132+ match (preserve, Path.is_file dst) with
133133+ | true, true -> `Exists ent
134134+ | _, false -> `Todo ent
135135+ | false, true -> `Todo ent
136136+ in
137137+ let file = relativize_path src_dir src in
138138+ let base =
139139+ let fname = Printf.sprintf "%s.webp" (Filename.chop_extension file) in
140140+ ent_of_dst fname w
141141+ in
142142+ let variants =
143143+ List.filter_map
144144+ (fun tw ->
145145+ if tw <= w then
146146+ let fname =
147147+ Printf.sprintf "%s.%d.webp" (Filename.chop_extension file) tw
148148+ in
149149+ Some (ent_of_dst fname tw)
150150+ else None)
151151+ img_widths
152152+ in
153153+ (base, variants)
154154+155155+let main_bar total =
156156+ let style =
157157+ let open Progress.Line.Bar_style in
158158+ let open Progress.Color in
159159+ let bars = ("|", "|") in
160160+ v ~delims:bars ~color:(hex "#FFBA08") [ "█"; "▓"; "▒"; "░"; " " ]
161161+ in
162162+ let open Progress.Line in
163163+ list [ bar ~style:(`Custom style) total; ticker_to total ]
164164+165165+let main_bar_heading head total =
166166+ let open Progress.Multi in
167167+ line (Progress.Line.const head) ++ line (main_bar total) ++ blank
168168+169169+let one_bar total =
170170+ let style =
171171+ let open Progress.Line.Bar_style in
172172+ let open Progress.Color in
173173+ v ~delims:("{", "}") ~color:(ansi `blue) [ "="; ">"; " " ]
174174+ in
175175+ let open Progress.Line in
176176+ let a =
177177+ list
178178+ [
179179+ spinner ();
180180+ bar ~style:(`Custom style) ~width:(`Fixed 12) total;
181181+ const " ";
182182+ ]
183183+ in
184184+ let b = string in
185185+ pair a b
186186+187187+let process_file cfg (display, main_rep) src =
188188+ let w, h = dims cfg src in
189189+ let needed_w = needed_sizes ~img_widths:cfg.img_widths ~w in
190190+ let ((base_src, base_dst, _, _) as base) = translate cfg src in
191191+ let needed = List.map (fun w -> translate cfg ~w src) needed_w in
192192+ let variants =
193193+ List.map (fun (_, dst, _, _) -> (dst, (0, 0))) needed
194194+ |> Srcsetter.MS.of_list
195195+ in
196196+ let slug = Filename.basename base_dst |> Filename.chop_extension in
197197+ (* TODO avsm check for clashing slugs *)
198198+ let ent = Srcsetter.v base_dst slug base_src variants (w, h) in
199199+ let todo =
200200+ List.filter_map
201201+ (fun (src, dst, sz, n) ->
202202+ let sz = match sz with None -> w | Some w -> w in
203203+ if n then Some (src, dst, sz) else None)
204204+ (base :: needed)
205205+ in
206206+ if List.length todo > 3 then (
207207+ let l = one_bar (List.length todo) in
208208+ let r = Progress.Display.add_line display l in
209209+ let fin = ref [] in
210210+ let rep sz =
211211+ if sz > 0 then fin := sz :: !fin;
212212+ let la = String.concat "," @@ List.map string_of_int !fin in
213213+ let flb =
214214+ Filename.basename (Path.native_exn src) |> Filename.chop_extension
215215+ in
216216+ let trim_string str max_length =
217217+ if String.length str <= max_length then str
218218+ else if max_length <= 3 then String.sub "..." 0 max_length
219219+ else
220220+ let trimmed_length = max_length - 3 in
221221+ let prefix = String.sub str 0 trimmed_length in
222222+ prefix ^ "..."
223223+ in
224224+ let label = Printf.sprintf "%25s -> %s" (trim_string flb 25) la in
225225+ Progress.Reporter.report r (1, label)
226226+ in
227227+ rep 0;
228228+ List.iter
229229+ (fun ((_, _, sz) as a) ->
230230+ rep sz;
231231+ convert cfg a)
232232+ todo;
233233+ main_rep 1;
234234+ Progress.Display.remove_line display r)
235235+ else (
236236+ List.iter (fun a -> convert cfg a) todo;
237237+ main_rep 1);
238238+ ent
+33
srcsetter-cmd.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "Image processing tool to generate responsive images"
44+description:
55+ "Srcsetter is a CLI tool that processes a directory of images and outputs a directory of responsive images suitable for embedding as `<img srcset` tags in a website. It uses the ImageMagick CLI tool to handle the actual processing of images."
66+maintainer: ["anil@recoil.org"]
77+authors: ["Anil Madhavapeddy"]
88+license: "ISC"
99+homepage: "https://github.com/avsm/srcsetter"
1010+bug-reports: "https://github.com/avsm/srcsetter/issues"
1111+depends: [
1212+ "dune" {>= "3.17"}
1313+ "srcsetter"
1414+ "fpath"
1515+ "progress"
1616+ "eio"
1717+ "odoc" {with-doc}
1818+]
1919+build: [
2020+ ["dune" "subst"] {dev}
2121+ [
2222+ "dune"
2323+ "build"
2424+ "-p"
2525+ name
2626+ "-j"
2727+ jobs
2828+ "@install"
2929+ "@runtest" {with-test}
3030+ "@doc" {with-doc}
3131+ ]
3232+]
3333+dev-repo: "git+https://github.com/avsm/srcsetter.git"
+32
srcsetter.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "Image srcset library for webp images"
44+description:
55+ "Srcsetter is a library that allows for handling a directory of responsive images suitable for embedding as `<img srcset` tags in a website. It uses the ImageMagick CLI tool to handle the actual processing of images, and the `srcsetter-cmd` package to generate the input to this library."
66+maintainer: ["anil@recoil.org"]
77+authors: ["Anil Madhavapeddy"]
88+license: "ISC"
99+homepage: "https://github.com/avsm/srcsetter"
1010+bug-reports: "https://github.com/avsm/srcsetter/issues"
1111+depends: [
1212+ "dune" {>= "3.17"}
1313+ "jsont"
1414+ "bytesrw"
1515+ "pro"
1616+ "odoc" {with-doc}
1717+]
1818+build: [
1919+ ["dune" "subst"] {dev}
2020+ [
2121+ "dune"
2222+ "build"
2323+ "-p"
2424+ name
2525+ "-j"
2626+ jobs
2727+ "@install"
2828+ "@runtest" {with-test}
2929+ "@doc" {with-doc}
3030+ ]
3131+]
3232+dev-repo: "git+https://github.com/avsm/srcsetter.git"