Skip to main content

pfx_assets/
lib.rs

1mod build;
2mod camera;
3#[cfg(test)]
4mod compare;
5mod daylight;
6mod layout;
7mod look;
8#[cfg(test)]
9mod look_check;
10mod motion;
11pub mod recipes;
12mod render;
13mod shots;
14mod video;
15
16use clap::{Parser, Subcommand};
17use daylight::Daylight;
18use layout::Instances;
19use motion::{Key, Pose};
20use pfx_materials::{encode_channel, linear_channel};
21use pfx_run::ending::Process;
22use pfx_run::job::{ASSETS_DETAIL, Job, TOOL, stages};
23use recipes::{Recipe, Recipes};
24use render::Sampling;
25use serde_json::{Map, Value, json};
26use sha2::{Digest, Sha256};
27use shots::Closure;
28use std::io::{BufRead, BufReader};
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31
32#[derive(Parser)]
33#[command(
34    name = "pfx",
35    about = "Path-traced renders of a product's logos, icons and scenes of them",
36    disable_help_flag = true,
37    disable_version_flag = true
38)]
39struct Cli {
40    #[command(subcommand)]
41    command: Kind,
42}
43
44#[derive(Subcommand)]
45enum Kind {
46    #[command(about = "Render a product's mark from its SVG master as a 3D solid")]
47    Logo {
48        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
49        product: String,
50        #[command(flatten)]
51        opts: Opts,
52    },
53    #[command(about = "Render a product's wordmark as a 3D solid")]
54    Wordmark {
55        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
56        product: String,
57        #[command(flatten)]
58        opts: Opts,
59    },
60    #[command(about = "Render a product's lockup (mark and word on one line)")]
61    Lockup {
62        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
63        product: String,
64        #[command(flatten)]
65        opts: Opts,
66    },
67    #[command(about = "Render the mark resting on the product's rounded-square tile")]
68    Tile {
69        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
70        product: String,
71        #[command(flatten)]
72        opts: Opts,
73    },
74    #[command(about = "Render an og:image card: the lockup on the product's ground, 1200x630")]
75    Card {
76        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
77        product: String,
78        #[command(flatten)]
79        opts: Opts,
80    },
81    #[command(about = "Render an icon set from icons/<product>/<set>.toml, one image per icon")]
82    Icons {
83        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
84        product: String,
85        #[arg(help = "Icon set: a file in icons/<product>/")]
86        set: String,
87        #[arg(long, help = "Render only this icon of the set")]
88        only: Option<String>,
89        #[command(flatten)]
90        opts: Opts,
91    },
92    #[command(about = "Render one frame of a shot from shots/<product>.toml")]
93    Still {
94        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
95        product: String,
96        #[arg(help = "Shot name from shots/<product>.toml, or a new name with --use")]
97        shot: String,
98        #[command(flatten)]
99        opts: Opts,
100    },
101    #[command(about = "Render a shot as a 60 fps clip with its master and poster")]
102    Clip {
103        #[arg(help = "The product's slug: lowercase letters, digits and '-'")]
104        product: String,
105        #[arg(help = "Shot name from shots/<product>.toml, or a new name with --use")]
106        shot: String,
107        #[command(flatten)]
108        opts: Opts,
109    },
110    #[command(about = "List the shots, for one product or all")]
111    Shots {
112        product: Option<String>,
113        #[arg(long = "recipes", help = "The recipes folder, or <product>=<dir>")]
114        recipes: Vec<String>,
115    },
116    #[command(about = "List the products' presets and icon sets")]
117    Presets {
118        #[arg(long = "recipes", help = "The recipes folder, or <product>=<dir>")]
119        recipes: Vec<String>,
120    },
121    #[command(about = "Turn an older clip's FFV1 master into an x264 master")]
122    Encode {
123        #[arg(help = "The clip's manifest .json")]
124        manifest: PathBuf,
125    },
126}
127
128#[derive(clap::Args, Clone)]
129struct Opts {
130    #[arg(long, help = "Largest delivery: N, WxH or 720p/1080p/1440p/2160p")]
131    size: Option<String>,
132    #[arg(
133        long,
134        help = "Most samples a pixel takes before adaptive sampling stops it (default 64, 12 for a draft)"
135    )]
136    samples: Option<u32>,
137    #[arg(
138        long,
139        help = "A quick draft: adaptive sampling to 0.05 with a cap of 12, denoised"
140    )]
141    draft: bool,
142    #[arg(
143        long,
144        help = "Keep the traced pixels as they are, without the denoiser"
145    )]
146    no_denoise: bool,
147    #[arg(long, help = "front, three-quarter, top, low, side, or <tilt>,<turn>")]
148    angle: Option<String>,
149    #[arg(long, help = "transparent, #rrggbb or hdri (clips need an opaque one)")]
150    background: Option<String>,
151    #[arg(
152        long,
153        default_value = "on",
154        help = "on or off: a shadow catcher under the subject"
155    )]
156    shadow: String,
157    #[arg(
158        long,
159        default_value_t = 1,
160        help = "Render N times larger and downscale"
161    )]
162    supersample: u32,
163    #[arg(
164        long = "set",
165        help = "Override a recipe key for this render: section.key=value"
166    )]
167    sets: Vec<String>,
168    #[arg(
169        long = "recipes",
170        help = "The recipes folder (default PFX_RECIPES, then <git toplevel>/render/assets), or <product>=<dir> for one product"
171    )]
172    recipes: Vec<String>,
173    #[arg(long, help = "Use this preset file instead of the recipes folder's")]
174    preset_file: Option<PathBuf>,
175    #[arg(long, help = "Use this shots file instead of the recipes folder's")]
176    shot_file: Option<PathBuf>,
177    #[arg(
178        long,
179        help = "The caller's marks folder with logos/, wordmarks/ and lockups/ (default PITO_ASSETS, then <git toplevel>/render/marks)"
180    )]
181    assets: Option<PathBuf>,
182    #[arg(
183        long,
184        default_value = "tmp/renders",
185        help = "Root for outputs, inside the caller's tmp/"
186    )]
187    out: PathBuf,
188    #[arg(
189        long,
190        value_delimiter = ',',
191        help = "Web copies at these widths, each also at @2x"
192    )]
193    web: Vec<u32>,
194    #[arg(
195        long,
196        value_delimiter = ',',
197        help = "More sizes from the same master, comma-separated"
198    )]
199    derive: Vec<String>,
200    #[arg(
201        long,
202        default_value = "truth",
203        help = "pgpu class: truth, clip, interactive or search"
204    )]
205    class: String,
206    #[arg(
207        long,
208        default_value_t = 7,
209        help = "Seed every random choice derives from"
210    )]
211    seed: u32,
212    #[arg(long, default_value_t = video::FPS, help = "Frames per second (60 only)")]
213    fps: u32,
214    #[arg(long, help = "A clip's length")]
215    seconds: Option<f64>,
216    #[arg(long, help = "The moment of a shot a still is taken, in seconds")]
217    at: Option<f64>,
218    #[arg(
219        long = "key",
220        allow_hyphen_values = true,
221        help = "A camera key: '<seconds> key=value ...', repeatable"
222    )]
223    keys: Vec<String>,
224    #[arg(
225        long,
226        allow_negative_numbers = true,
227        help = "Walk the camera round the vertical axis, degrees"
228    )]
229    turn: Option<f64>,
230    #[arg(
231        long,
232        allow_negative_numbers = true,
233        help = "Camera angle from straight overhead, degrees"
234    )]
235    tilt: Option<f64>,
236    #[arg(
237        long,
238        allow_negative_numbers = true,
239        help = "Turn the picture about the line of sight, degrees"
240    )]
241    roll: Option<f64>,
242    #[arg(long, help = "Closeness: 1 is framed, 2 twice as close")]
243    zoom: Option<f64>,
244    #[arg(
245        long = "move",
246        allow_hyphen_values = true,
247        help = "Shift the view: x,y in picture heights"
248    )]
249    shift: Option<String>,
250    #[arg(
251        long,
252        allow_hyphen_values = true,
253        help = "Point the camera looks at: x,y,z"
254    )]
255    target: Option<String>,
256    #[arg(long, help = "Focal length, 35 mm equivalent")]
257    lens: Option<f64>,
258    #[arg(long, help = "Orthographic camera")]
259    ortho: bool,
260    #[arg(long, help = "Framed view over the subject at zoom 1 (1.12 leaves 6%)")]
261    margin: Option<f64>,
262    #[arg(long, help = "Copies of each asset: 1, 10, 1000 ...")]
263    count: Option<u32>,
264    #[arg(long, help = "grid, line, ring, spiral or scatter")]
265    layout: Option<String>,
266    #[arg(long, help = "A grid's columns")]
267    columns: Option<u32>,
268    #[arg(long, help = "Centre to centre, in subject widths")]
269    spacing: Option<f64>,
270    #[arg(long, help = "0 to 1: random turn, lean and offset per copy")]
271    jitter: Option<f64>,
272    #[arg(long, help = "Each copy's size")]
273    scale: Option<f64>,
274    #[arg(
275        long = "use",
276        help = "Build a shot on the command line: <kind>:<product>[:<name>]"
277    )]
278    uses: Vec<String>,
279    #[arg(long, help = "Skip the archive step")]
280    no_archive: bool,
281    #[arg(long, help = "Keep the rendered frames in scratch")]
282    keep_frames: bool,
283    #[arg(long, help = "Write the master only")]
284    no_encode: bool,
285    #[arg(long, help = "Daylight: local solar time, 0 to 24")]
286    hour: Option<f64>,
287    #[arg(long, help = "Daylight: day of the year (default 172)")]
288    day: Option<f64>,
289    #[arg(
290        long,
291        allow_negative_numbers = true,
292        help = "Daylight: degrees north (default 45)"
293    )]
294    latitude: Option<f64>,
295    #[arg(
296        long,
297        allow_negative_numbers = true,
298        help = "Daylight: bearing the scene faces (default 180)"
299    )]
300    heading: Option<f64>,
301}
302
303struct Asset {
304    kind: String,
305    product: String,
306    name: Option<String>,
307    svg: Option<PathBuf>,
308    icon: Option<Value>,
309    preset: Value,
310    preset_text: String,
311    recipe: Value,
312    icon_set: Option<Value>,
313    picked_by: String,
314}
315
316struct Entry {
317    assets: Vec<Asset>,
318    inst: Instances,
319    at: [f64; 3],
320    turn: f64,
321    tilt: f64,
322}
323
324struct Scene {
325    product: String,
326    folder: String,
327    base: String,
328    source: Value,
329    preset: Value,
330    preset_sha: Option<String>,
331    shot: Option<(String, String)>,
332    entries: Vec<Entry>,
333    camera: Pose,
334    keys: Vec<Key>,
335    closure: Closure,
336    seconds: Option<f64>,
337    single: bool,
338    shadow: bool,
339    daylight: Daylight,
340    recipes: Vec<Value>,
341}
342
343fn describe(kind: &Kind) -> Option<(Option<&str>, String, bool)> {
344    fn one<'a>(what: &str, product: &'a str) -> Option<(Option<&'a str>, String, bool)> {
345        Some((Some(product), format!("{what} {product}"), true))
346    }
347    match kind {
348        Kind::Presets { .. } | Kind::Shots { .. } => None,
349        Kind::Logo { product, .. } => one("logo", product),
350        Kind::Wordmark { product, .. } => one("wordmark", product),
351        Kind::Lockup { product, .. } => one("lockup", product),
352        Kind::Tile { product, .. } => one("tile", product),
353        Kind::Card { product, .. } => one("card", product),
354        Kind::Icons {
355            product, set, only, ..
356        } => Some((
357            Some(product),
358            match only {
359                Some(icon) => format!("icons {product} {set} {icon}"),
360                None => format!("icons {product} {set}"),
361            },
362            false,
363        )),
364        Kind::Still { product, shot, .. } => {
365            Some((Some(product), format!("still {product} {shot}"), true))
366        }
367        Kind::Clip { product, shot, .. } => {
368            Some((Some(product), format!("clip {product} {shot}"), false))
369        }
370        Kind::Encode { manifest, .. } => {
371            Some((None, format!("encode {}", manifest.display()), false))
372        }
373    }
374}
375
376fn parse(args: &[String]) -> Result<Cli, String> {
377    let full = std::iter::once(String::from("pfx")).chain(args.iter().cloned());
378    Cli::try_parse_from(full).map_err(|error| {
379        let text = error.to_string();
380        let text = text.strip_prefix("error: ").unwrap_or(&text);
381        let end = text
382            .find("\n\nUsage:")
383            .or_else(|| text.find("\n\nFor more information"))
384            .unwrap_or(text.len());
385        text[..end].trim_end().to_string()
386    })
387}
388
389pub fn check(args: &[String]) -> Result<(), String> {
390    parse(args).map(|_| ())
391}
392
393pub fn run(args: &[String]) -> Result<(), String> {
394    if let Some(job) = std::env::var_os(render::JOB_VAR) {
395        return render::serve(Path::new(&job));
396    }
397    let cli = parse(args)?;
398    if let Some(product) = product_of(&cli.command) {
399        recipes::slug(product)?;
400    }
401    if let Some((app, label, single)) = describe(&cli.command) {
402        let mut live = Job::start(TOOL, app, &label);
403        live.plan(plan_of(&cli.command));
404        live.detail(format!("{ASSETS_DETAIL}{label}"));
405        if single {
406            live.stage(stages::TRACE, Some(1), "renders");
407        }
408        let result = run_kind(&cli.command, &mut live);
409        live.end(result)
410    } else {
411        match cli.command {
412            Kind::Presets { recipes } => list_presets(&recipes_of(&recipes)?),
413            Kind::Shots { product, recipes } => {
414                list_shots(&recipes_of(&recipes)?, product.as_deref())
415            }
416            _ => unreachable!(),
417        }
418    }
419}
420
421fn product_of(kind: &Kind) -> Option<&str> {
422    match kind {
423        Kind::Logo { product, .. }
424        | Kind::Wordmark { product, .. }
425        | Kind::Lockup { product, .. }
426        | Kind::Tile { product, .. }
427        | Kind::Card { product, .. }
428        | Kind::Icons { product, .. }
429        | Kind::Still { product, .. }
430        | Kind::Clip { product, .. } => Some(product),
431        Kind::Shots { product, .. } => product.as_deref(),
432        Kind::Presets { .. } | Kind::Encode { .. } => None,
433    }
434}
435
436fn recipes_of(values: &[String]) -> Result<Recipes, String> {
437    Recipes::from_flags(
438        values,
439        std::env::var_os(recipes::VARIABLE).map(PathBuf::from),
440        || {
441            std::env::current_dir()
442                .ok()
443                .and_then(|dir| recipes::toplevel(&dir))
444        },
445    )
446}
447
448fn plan_of(kind: &Kind) -> &'static [&'static str] {
449    match kind {
450        Kind::Clip { .. } => stages::ASSET_CLIP,
451        Kind::Encode { .. } => stages::ASSET_ENCODE,
452        _ => stages::ASSET_STILL,
453    }
454}
455
456fn run_kind(kind: &Kind, live: &mut Job) -> Result<(), String> {
457    let found = |opts: &Opts| recipes_of(&opts.recipes);
458    match kind {
459        Kind::Logo { product, opts } => single(opts, &found(opts)?, product, "logo", None, live),
460        Kind::Wordmark { product, opts } => {
461            single(opts, &found(opts)?, product, "wordmark", None, live)
462        }
463        Kind::Lockup { product, opts } => {
464            single(opts, &found(opts)?, product, "lockup", None, live)
465        }
466        Kind::Tile { product, opts } => single(opts, &found(opts)?, product, "tile", None, live),
467        Kind::Card { product, opts } => card(opts, &found(opts)?, product, live),
468        Kind::Icons {
469            product,
470            set,
471            only,
472            opts,
473        } => icons(opts, &found(opts)?, product, set, only.as_deref(), live),
474        Kind::Still {
475            product,
476            shot,
477            opts,
478        } => shot_scene(opts, &found(opts)?, product, shot, "still")
479            .and_then(|s| still(opts, &s, live)),
480        Kind::Clip {
481            product,
482            shot,
483            opts,
484        } => shot_scene(opts, &found(opts)?, product, shot, "clip")
485            .and_then(|s| clip(opts, &s, live)),
486        Kind::Encode { manifest } => reencode(manifest, live),
487        Kind::Presets { .. } | Kind::Shots { .. } => unreachable!(),
488    }
489}
490
491fn size_of(text: &str) -> Result<(u32, u32), String> {
492    if let Some(size) = video::video_size(text) {
493        return Ok(size);
494    }
495    let parse = |s: &str| {
496        s.trim()
497            .parse::<u32>()
498            .ok()
499            .filter(|n| *n > 0)
500            .ok_or_else(|| format!("bad size {text}"))
501    };
502    match text.split_once('x') {
503        Some((w, h)) => Ok((parse(w)?, parse(h)?)),
504        None => {
505            let n = parse(text)?;
506            Ok((n, n))
507        }
508    }
509}
510
511fn sha(bytes: &[u8]) -> String {
512    Sha256::digest(bytes)
513        .iter()
514        .map(|b| format!("{b:02x}"))
515        .collect()
516}
517
518fn toml_json(text: &str, what: &str) -> Result<Value, String> {
519    let table: toml::Table = text.parse().map_err(|e| format!("{what}: {e}"))?;
520    serde_json::to_value(table).map_err(|e| e.to_string())
521}
522
523fn apply_sets(value: &mut Value, sets: &[String]) -> Result<(), String> {
524    for set in sets {
525        let (key, raw) = set
526            .split_once('=')
527            .ok_or_else(|| format!("--set wants key=value, got {set}"))?;
528        let parsed: Value = serde_json::from_str(raw).unwrap_or_else(|_| json!(raw));
529        let parts: Vec<&str> = key.split('.').collect();
530        let mut at = &mut *value;
531        for part in &parts[..parts.len() - 1] {
532            at = at
533                .as_object_mut()
534                .ok_or("--set reaches into something that is not a table")?
535                .entry(part.to_string())
536                .or_insert_with(|| json!({}));
537        }
538        at.as_object_mut()
539            .ok_or("--set reaches into something that is not a table")?
540            .insert(parts[parts.len() - 1].to_string(), parsed);
541    }
542    Ok(())
543}
544
545fn preset_recipe(recipes: &Recipes, product: &str, file: Option<&Path>) -> Result<Recipe, String> {
546    match file {
547        Some(path) => recipes::file(path),
548        None => {
549            recipes::slug(product)?;
550            recipes.read(&format!("presets/{product}.toml"))
551        }
552    }
553}
554
555pub const MARKS: &str = "render/marks";
556const MARKS_HOW: &str = "pass --assets <dir>, set PITO_ASSETS, or keep the product's masters in the caller's own render/marks/{logos,wordmarks,lockups}/<product>.svg";
557
558fn assets_dir(opts: &Opts) -> Option<PathBuf> {
559    opts.assets
560        .clone()
561        .or_else(|| {
562            std::env::var_os("PITO_ASSETS")
563                .filter(|dir| !dir.is_empty())
564                .map(PathBuf::from)
565        })
566        .or_else(|| {
567            std::env::current_dir()
568                .ok()
569                .and_then(|dir| recipes::toplevel(&dir))
570                .map(|top| top.join(MARKS))
571        })
572}
573
574fn master_path(opts: &Opts, kind: &str, product: &str) -> Option<PathBuf> {
575    let folder = match kind {
576        "wordmark" => "wordmarks",
577        "lockup" => "lockups",
578        _ => "logos",
579    };
580    assets_dir(opts).map(|dir| dir.join(folder).join(format!("{product}.svg")))
581}
582
583fn icon_set(recipes: &Recipes, product: &str, set: &str) -> Result<(Vec<Value>, Recipe), String> {
584    recipes::slug(product)?;
585    if set.is_empty() || set.contains(['/', '\\']) || set.starts_with('.') {
586        return Err(format!("'{set}' is not an icon set's name"));
587    }
588    let rel = format!("icons/{product}/{set}.toml");
589    let recipe = recipes.read(&rel)?;
590    let table = toml_json(&recipe.text, &rel)?;
591    let table = table.as_object().ok_or("icon set is not a table")?;
592    if let Some(extra) = table.keys().find(|k| k.as_str() != "icon") {
593        return Err(format!(
594            "{rel}: unknown top-level key {extra}; only [[icon]] entries are allowed"
595        ));
596    }
597    let list = table
598        .get("icon")
599        .and_then(Value::as_array)
600        .cloned()
601        .ok_or_else(|| format!("{rel} has no [[icon]] entries"))?;
602    Ok((list, recipe))
603}
604
605fn icon_names(
606    recipes: &Recipes,
607    products: &[String],
608) -> Result<Vec<(String, String, String)>, String> {
609    let mut out = Vec::new();
610    for product in products {
611        for set in recipes.icon_sets(product) {
612            for spec in icon_set(recipes, product, &set)?.0 {
613                let name = spec.get("name").and_then(Value::as_str).unwrap_or("icon");
614                out.push((product.clone(), set.clone(), name.to_string()));
615            }
616        }
617    }
618    Ok(out)
619}
620
621fn camera_flags(opts: &Opts) -> Map<String, Value> {
622    let mut map = Map::new();
623    for (key, value) in [
624        ("turn", opts.turn),
625        ("tilt", opts.tilt),
626        ("roll", opts.roll),
627        ("zoom", opts.zoom),
628        ("lens", opts.lens),
629        ("margin", opts.margin),
630    ] {
631        if let Some(v) = value {
632            map.insert(key.into(), json!(v));
633        }
634    }
635    if let Some(v) = &opts.shift {
636        map.insert("move".into(), json!(v));
637    }
638    if let Some(v) = &opts.target {
639        map.insert("target".into(), json!(v));
640    }
641    if opts.ortho {
642        map.insert("ortho".into(), json!(true));
643    }
644    map
645}
646
647fn instance_flags(opts: &Opts) -> Map<String, Value> {
648    let mut map = Map::new();
649    if let Some(v) = opts.count {
650        map.insert("count".into(), json!(v));
651    }
652    if let Some(v) = &opts.layout {
653        map.insert("layout".into(), json!(v));
654    }
655    if let Some(v) = opts.columns {
656        map.insert("columns".into(), json!(v));
657    }
658    for (key, value) in [
659        ("spacing", opts.spacing),
660        ("jitter", opts.jitter),
661        ("scale", opts.scale),
662    ] {
663        if let Some(v) = value {
664            map.insert(key.into(), json!(v));
665        }
666    }
667    map
668}
669
670fn camera(opts: &Opts, tables: &[(&str, Option<&Value>)]) -> Result<Pose, String> {
671    let mut pose = Pose::default();
672    let (tilt, turn) = motion::angle("front")?;
673    pose.tilt = tilt;
674    pose.turn = turn;
675    for (place, table) in tables {
676        if let Some(table) = table {
677            let map = table
678                .as_object()
679                .ok_or_else(|| format!("{place}: camera is a table"))?;
680            pose.apply(map, place)?;
681        }
682    }
683    if let Some(text) = &opts.angle {
684        let (tilt, turn) = motion::angle(text)?;
685        pose.tilt = tilt;
686        pose.turn = turn;
687    }
688    pose.apply(&camera_flags(opts), "flags")?;
689    Ok(pose)
690}
691
692fn daylight(opts: &Opts, table: Option<&Value>) -> Result<Daylight, String> {
693    let mut d = Daylight::default();
694    if let Some(table) = table {
695        d.apply(
696            table.as_object().ok_or("daylight is a table")?,
697            "shot daylight",
698        )?;
699    }
700    let mut flags = Map::new();
701    for (key, value) in [
702        ("hour", opts.hour),
703        ("day", opts.day),
704        ("latitude", opts.latitude),
705        ("heading", opts.heading),
706    ] {
707        if let Some(v) = value {
708            flags.insert(key.into(), json!(v));
709        }
710    }
711    d.apply(&flags, "flags")?;
712    Ok(d)
713}
714
715fn cli_keys(opts: &Opts) -> Result<Vec<Key>, String> {
716    opts.keys.iter().map(|k| Key::parse(k)).collect()
717}
718
719const KIND_SECTIONS: &[&str] = &[
720    "colors",
721    "view",
722    "look",
723    "exposure",
724    "denoise",
725    "material",
726    "materials",
727    "form",
728    "rig",
729    "ground",
730    "style",
731    "outline",
732    "camera",
733];
734
735fn for_kind(preset: &mut Value, kind: &str) -> Result<(), String> {
736    let Some(over) = preset.get("kind").and_then(|k| k.get(kind)).cloned() else {
737        return Ok(());
738    };
739    let over = over
740        .as_object()
741        .ok_or_else(|| format!("preset [kind.{kind}] is a table"))?;
742    let map = preset.as_object_mut().ok_or("preset is not a table")?;
743    for (section, value) in over {
744        if !KIND_SECTIONS.contains(&section.as_str()) {
745            return Err(format!(
746                "preset [kind.{kind}]: unknown section {section}; allowed: {}",
747                KIND_SECTIONS.join(", ")
748            ));
749        }
750        match (value, map.get_mut(section)) {
751            (Value::Object(add), Some(Value::Object(have))) => {
752                for (k, v) in add {
753                    have.insert(k.clone(), v.clone());
754                }
755            }
756            _ => {
757                map.insert(section.clone(), value.clone());
758            }
759        }
760    }
761    Ok(())
762}
763
764fn without_kinds(preset: &Value) -> Value {
765    let mut clean = preset.clone();
766    if let Some(map) = clean.as_object_mut() {
767        map.remove("kind");
768    }
769    clean
770}
771
772#[allow(clippy::too_many_arguments)]
773fn asset(
774    opts: &Opts,
775    recipes: &Recipes,
776    kind: &str,
777    product: &str,
778    name: Option<&str>,
779    picked_by: &str,
780    preset_product: &str,
781    file: Option<&Path>,
782) -> Result<Asset, String> {
783    let recipe = preset_recipe(recipes, preset_product, file)?;
784    let mut preset = toml_json(&recipe.text, "preset")?;
785    for_kind(&mut preset, kind)?;
786    let (svg, icon, icon_record, asset_kind) = if kind == "icon" {
787        let full = name.ok_or("an icon needs a name")?;
788        let (set, icon) = full
789            .split_once('/')
790            .ok_or("an icon's name is <set>/<icon>")?;
791        let (list, set_recipe) = icon_set(recipes, product, set)?;
792        let spec = list
793            .into_iter()
794            .find(|s| s.get("name").and_then(Value::as_str) == Some(icon))
795            .ok_or_else(|| format!("no icon {icon} in icons/{product}/{set}.toml"))?;
796        (None, Some(spec), Some(set_recipe.record), "icons")
797    } else {
798        let path = master_path(opts, kind, product)
799            .ok_or_else(|| format!("a {kind} reads its SVG master: {MARKS_HOW}"))?;
800        if !path.is_file() {
801            return Err(format!("no master at {}: {MARKS_HOW}", path.display()));
802        }
803        (Some(path), None, None, kind)
804    };
805    Ok(Asset {
806        kind: asset_kind.to_string(),
807        product: product.to_string(),
808        name: name.map(str::to_string),
809        svg,
810        icon,
811        preset,
812        preset_text: recipe.text,
813        recipe: recipe.record,
814        icon_set: icon_record,
815        picked_by: picked_by.to_string(),
816    })
817}
818
819fn gathered(scene_records: &[&Value], entries: &[Entry]) -> Vec<Value> {
820    let mut out: Vec<Value> = Vec::new();
821    let assets = entries.iter().flat_map(|e| &e.assets);
822    let records = scene_records
823        .iter()
824        .copied()
825        .chain(assets.flat_map(|a| std::iter::once(&a.recipe).chain(a.icon_set.as_ref())));
826    for record in records {
827        if !out.contains(record) {
828            out.push(record.clone());
829        }
830    }
831    out
832}
833
834fn recipes_pinned(scene: &Scene) -> bool {
835    scene
836        .recipes
837        .iter()
838        .all(|record| record["pinned"].as_bool() == Some(true))
839}
840
841fn single(
842    opts: &Opts,
843    recipes: &Recipes,
844    product: &str,
845    kind: &str,
846    icon: Option<(&str, &str)>,
847    live: &mut Job,
848) -> Result<(), String> {
849    let scene = single_scene(opts, recipes, product, kind, icon)?;
850    still(opts, &scene, live)
851}
852
853fn single_scene(
854    opts: &Opts,
855    recipes: &Recipes,
856    product: &str,
857    kind: &str,
858    icon: Option<(&str, &str)>,
859) -> Result<Scene, String> {
860    let name = icon.map(|(set, icon)| format!("{set}/{icon}"));
861    let picked = match &name {
862        Some(n) => format!("icon:{product}:{n}"),
863        None => format!("{kind}:{product}"),
864    };
865    let mut a = asset(
866        opts,
867        recipes,
868        match (icon.is_some(), kind) {
869            (true, _) => "icon",
870            (false, "card") => "lockup",
871            (false, other) => other,
872        },
873        product,
874        name.as_deref(),
875        &picked,
876        product,
877        opts.preset_file.as_deref(),
878    )?;
879    if kind == "card" {
880        for_kind(&mut a.preset, "card")?;
881    }
882    apply_sets(&mut a.preset, &opts.sets)?;
883    let source = match (&a.svg, icon) {
884        (Some(path), _) => {
885            let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
886            json!({"path": path.display().to_string(), "sha256": sha(&bytes)})
887        }
888        (None, Some(_)) => {
889            let record = a.icon_set.clone().unwrap_or_default();
890            json!({"path": record["path"], "repo": record["repo"], "commit": record["commit"], "sha256": record["sha256"]})
891        }
892        (None, None) => json!({"path": Value::Null, "sha256": Value::Null}),
893    };
894    let mut camera = camera(opts, &[("preset [camera]", a.preset.get("camera"))])?;
895    let daylight = daylight(opts, None)?;
896    camera.hour = daylight.hour;
897    let keys = motion::sorted(cli_keys(opts)?)?;
898    let mut inst = Instances::default();
899    let flags = instance_flags(opts);
900    inst.apply(&flags, "flags")?;
901    let lone = flags.is_empty() || (inst.count == 1 && inst.jitter == 0.0 && inst.scale == 1.0);
902    let (folder, base) = match icon {
903        Some((_, i)) => ("icon".to_string(), format!("{product}-icon-{i}")),
904        None => (kind.to_string(), format!("{product}-{kind}")),
905    };
906    let preset = a.preset.clone();
907    let preset_sha = Some(sha(a.preset_text.as_bytes()));
908    let entries = vec![Entry {
909        assets: vec![a],
910        inst,
911        at: [0.0; 3],
912        turn: 0.0,
913        tilt: 0.0,
914    }];
915    let recipes = gathered(&[], &entries);
916    Ok(Scene {
917        product: product.to_string(),
918        folder,
919        base,
920        source,
921        preset,
922        preset_sha,
923        shot: None,
924        entries,
925        camera,
926        keys,
927        closure: Closure::Open,
928        seconds: None,
929        single: lone,
930        shadow: opts.shadow == "on" && !(lone && kind == "tile"),
931        daylight,
932        recipes,
933    })
934}
935
936fn card(opts: &Opts, recipes: &Recipes, product: &str, live: &mut Job) -> Result<(), String> {
937    let card = card_opts(opts, recipes, product)?;
938    single(&card, recipes, product, "card", None, live)
939}
940
941fn card_opts(opts: &Opts, recipes: &Recipes, product: &str) -> Result<Opts, String> {
942    let text = preset_recipe(recipes, product, opts.preset_file.as_deref())?.text;
943    let ground = toml_json(&text, "preset")?
944        .get("ground")
945        .and_then(|g| g.get("color"))
946        .and_then(Value::as_str)
947        .unwrap_or("#24282C")
948        .to_string();
949    let mut card = opts.clone();
950    card.size.get_or_insert_with(|| "1200x630".into());
951    card.background.get_or_insert(ground);
952    card.margin.get_or_insert(1.3);
953    Ok(card)
954}
955
956fn icons(
957    opts: &Opts,
958    recipes: &Recipes,
959    product: &str,
960    set: &str,
961    only: Option<&str>,
962    live: &mut Job,
963) -> Result<(), String> {
964    let names: Vec<String> = icon_set(recipes, product, set)?
965        .0
966        .iter()
967        .map(|spec| spec.get("name").and_then(Value::as_str).unwrap_or("icon"))
968        .filter(|name| only.is_none_or(|o| o == *name))
969        .map(str::to_string)
970        .collect();
971    live.stage(stages::TRACE, Some(names.len() as u64), "renders");
972    for name in &names {
973        single(opts, recipes, product, "icons", Some((set, name)), live)?;
974    }
975    Ok(())
976}
977
978fn book(recipes: &Recipes, product: &str, file: Option<&Path>) -> Result<(String, Recipe), String> {
979    match file {
980        Some(path) => Ok((path.display().to_string(), recipes::file(path)?)),
981        None => {
982            let rel = format!("shots/{product}.toml");
983            Ok((rel.clone(), recipes.read(&rel)?))
984        }
985    }
986}
987
988fn list_presets(recipes: &Recipes) -> Result<(), String> {
989    for name in recipes.presets() {
990        let text = recipes.read(&format!("presets/{name}.toml"))?.text;
991        println!("{name}\t{}", text.lines().next().unwrap_or(""));
992    }
993    for product in recipes.icon_products() {
994        for set in recipes.icon_sets(&product) {
995            println!("icons\t{product} {set}");
996        }
997    }
998    Ok(())
999}
1000
1001fn list_shots(recipes: &Recipes, product: Option<&str>) -> Result<(), String> {
1002    for p in recipes.books() {
1003        if product.is_some_and(|want| want != p) {
1004            continue;
1005        }
1006        let rel = format!("shots/{p}.toml");
1007        let table = toml_json(&recipes.read(&rel)?.text, &rel)?;
1008        if let Some(list) = table.get("shots").and_then(Value::as_object) {
1009            for (name, shot) in list {
1010                let line = shot.get("line").and_then(Value::as_str).unwrap_or("");
1011                println!("{p}\t{name}\t{line}");
1012            }
1013        }
1014    }
1015    Ok(())
1016}
1017
1018fn scene_preset(
1019    recipes: &Recipes,
1020    product: &str,
1021    shot: &Map<String, Value>,
1022) -> Result<(Value, Option<Recipe>), String> {
1023    let look = shot
1024        .get("preset")
1025        .and_then(Value::as_str)
1026        .unwrap_or(product);
1027    recipes::slug(look)?;
1028    let rel = format!("presets/{look}.toml");
1029    let (mut base, recipe) = match recipes.find(&rel)? {
1030        Some(recipe) => (toml_json(&recipe.text, "preset")?, Some(recipe)),
1031        None if shot.get("preset").is_none() => (json!({}), None),
1032        None => return Err(recipes.missing("recipe", &rel)),
1033    };
1034    let map = base.as_object_mut().ok_or("preset is not a table")?;
1035    for section in shots::SCENE_SECTIONS {
1036        match (shot.get(*section), map.get_mut(*section)) {
1037            (Some(Value::Object(add)), Some(Value::Object(have))) => {
1038                for (k, v) in add {
1039                    have.insert(k.clone(), v.clone());
1040                }
1041            }
1042            (Some(v), _) => {
1043                map.insert(section.to_string(), v.clone());
1044            }
1045            _ => {}
1046        }
1047    }
1048    Ok((base, recipe))
1049}
1050
1051fn shot_scene(
1052    opts: &Opts,
1053    recipes: &Recipes,
1054    product: &str,
1055    name: &str,
1056    kind: &str,
1057) -> Result<Scene, String> {
1058    let mut recipes = recipes.clone();
1059    let (source, book_recipe, mut shot) = if opts.uses.is_empty() {
1060        let (path, recipe) = book(&recipes, product, opts.shot_file.as_deref())?;
1061        recipes.list_products(&recipe.text, &path)?;
1062        let all = toml_json(&recipe.text, &path)?;
1063        let shot = all
1064            .get("shots")
1065            .and_then(|s| s.get(name))
1066            .cloned()
1067            .ok_or_else(|| {
1068                let known: Vec<String> = all
1069                    .get("shots")
1070                    .and_then(Value::as_object)
1071                    .map(|m| m.keys().cloned().collect())
1072                    .unwrap_or_default();
1073                format!("no shot {name} in {path}; known: {}", known.join(", "))
1074            })?;
1075        (path, Some(recipe), shot)
1076    } else {
1077        let assets: Vec<Value> = opts.uses.iter().map(|u| json!({"use": u})).collect();
1078        ("command line".to_string(), None, json!({"assets": assets}))
1079    };
1080    apply_sets(&mut shot, &opts.sets)?;
1081    let map = shot.as_object().ok_or("a shot is a table")?.clone();
1082    shots::check_shot(name, &map)?;
1083    let (preset, preset_recipe) = scene_preset(&recipes, product, &map)?;
1084    let preset_sha = preset_recipe.as_ref().map(|r| r.sha256());
1085    let mut camera = camera(
1086        opts,
1087        &[
1088            ("preset [camera]", preset.get("camera")),
1089            ("shot camera", map.get("camera")),
1090        ],
1091    )?;
1092    let daylight = daylight(opts, map.get("daylight"))?;
1093    camera.hour = daylight.hour;
1094    let keys = if opts.keys.is_empty() {
1095        map.get("keys")
1096            .and_then(Value::as_array)
1097            .map(|list| {
1098                list.iter()
1099                    .enumerate()
1100                    .map(|(i, k)| {
1101                        let place = format!("shot {name} key {i}");
1102                        k.as_object()
1103                            .ok_or_else(|| format!("{place}: a key is a table"))
1104                            .and_then(|t| Key::from_table(t, &place))
1105                    })
1106                    .collect::<Result<Vec<_>, _>>()
1107            })
1108            .transpose()?
1109            .unwrap_or_default()
1110    } else {
1111        cli_keys(opts)?
1112    };
1113    let keys = motion::sorted(keys)?;
1114    let closure = Closure::read(map.get("closure"))?;
1115    let seconds = opts
1116        .seconds
1117        .or_else(|| map.get("seconds").and_then(Value::as_f64))
1118        .or_else(|| keys.last().map(|k| k.at).filter(|at| *at > 0.0));
1119    let universe = recipes.universe(product);
1120    let products: Vec<&str> = universe.iter().map(String::as_str).collect();
1121    let names = icon_names(&recipes, &universe)?;
1122    let exists =
1123        |kind: &str, p: &str| master_path(opts, kind, p).is_some_and(|path| path.is_file());
1124    let flags = instance_flags(opts);
1125    let mut entries = Vec::new();
1126    for (i, entry) in map["assets"].as_array().into_iter().flatten().enumerate() {
1127        let place = format!("shot {name} asset {i}");
1128        let entry = entry.as_object().ok_or("an asset entry is a table")?;
1129        let used = entry["use"].as_str().unwrap_or_default();
1130        let picks = shots::resolve(used, &products, &names, &exists)
1131            .map_err(|e| format!("{place}: {e}"))?;
1132        let look = entry.get("preset").and_then(Value::as_str);
1133        let mut assets = Vec::new();
1134        for pick in picks {
1135            assets.push(asset(
1136                opts,
1137                &recipes,
1138                &pick.kind,
1139                &pick.product,
1140                pick.name.as_deref(),
1141                used,
1142                look.unwrap_or(&pick.product),
1143                None,
1144            )?);
1145        }
1146        let mut inst = Instances::default();
1147        inst.apply(entry, &place)?;
1148        inst.apply(&flags, "flags")?;
1149        let number = |key: &str| entry.get(key).and_then(Value::as_f64).unwrap_or(0.0);
1150        let at = match entry.get("at") {
1151            Some(v) => {
1152                let list: Vec<f64> = v
1153                    .as_array()
1154                    .map(|a| a.iter().filter_map(Value::as_f64).collect())
1155                    .unwrap_or_default();
1156                match list[..] {
1157                    [x, y] => [x, y, 0.0],
1158                    [x, y, z] => [x, y, z],
1159                    _ => return Err(format!("{place}: at is [x, y] or [x, y, z]")),
1160                }
1161            }
1162            None => [0.0; 3],
1163        };
1164        entries.push(Entry {
1165            assets,
1166            inst,
1167            at,
1168            turn: number("turn"),
1169            tilt: number("tilt"),
1170        });
1171    }
1172    let shot_sha = sha(serde_json::to_string(&shot).unwrap_or_default().as_bytes());
1173    let source = match &book_recipe {
1174        Some(recipe) => json!({
1175            "path": source,
1176            "repo": recipe.record["repo"],
1177            "commit": recipe.record["commit"],
1178            "sha256": recipe.sha256(),
1179        }),
1180        None => json!({"path": source, "sha256": Value::Null}),
1181    };
1182    let scene_records: Vec<&Value> = book_recipe
1183        .iter()
1184        .chain(preset_recipe.iter())
1185        .map(|r| &r.record)
1186        .collect();
1187    let recipes = gathered(&scene_records, &entries);
1188    Ok(Scene {
1189        product: product.to_string(),
1190        folder: kind.to_string(),
1191        base: format!("{product}-{kind}-{name}"),
1192        source,
1193        preset,
1194        preset_sha,
1195        shot: Some((name.to_string(), shot_sha)),
1196        entries,
1197        camera,
1198        keys,
1199        closure,
1200        seconds,
1201        single: false,
1202        shadow: opts.shadow == "on",
1203        daylight,
1204        recipes,
1205    })
1206}
1207
1208fn scratch() -> Result<PathBuf, String> {
1209    let dir = std::env::current_dir()
1210        .map_err(|e| e.to_string())?
1211        .join("tmp/pito-asset-renderer");
1212    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
1213    Ok(dir)
1214}
1215
1216fn sampling(opts: &Opts) -> Sampling {
1217    Sampling {
1218        denoise: !opts.no_denoise,
1219        ..Sampling::chosen(opts.draft, opts.samples)
1220    }
1221}
1222
1223fn job(
1224    opts: &Opts,
1225    scene: &Scene,
1226    size: (u32, u32),
1227    background: &str,
1228    poses: &[Pose],
1229) -> Result<Value, String> {
1230    let aspect = size.0 as f64 / size.1 as f64;
1231    let mut assets = Vec::new();
1232    for (g, entry) in scene.entries.iter().enumerate() {
1233        let m = entry.assets.len();
1234        let n = entry.inst.count as usize * m;
1235        let layout = entry.inst.chosen(m);
1236        let copies = if scene.single {
1237            vec![layout::Copy {
1238                at: [0.0; 3],
1239                turn: 0.0,
1240                tilt: 0.0,
1241                scale: 1.0,
1242            }]
1243        } else {
1244            layout::place(
1245                n,
1246                &entry.inst,
1247                layout,
1248                opts.seed as u64 + g as u64 * 1_000_003,
1249                aspect,
1250            )
1251        };
1252        for (j, a) in entry.assets.iter().enumerate() {
1253            let mine: Vec<Value> = copies
1254                .iter()
1255                .enumerate()
1256                .filter(|(k, _)| k % m == j)
1257                .map(|(_, c)| {
1258                    json!({
1259                        "at": c.at,
1260                        "turn": c.turn + entry.turn,
1261                        "tilt": c.tilt + entry.tilt,
1262                        "scale": c.scale,
1263                    })
1264                })
1265                .collect();
1266            let mut item = json!({
1267                "kind": a.kind,
1268                "preset": without_kinds(&a.preset),
1269                "group": g,
1270                "offset": entry.at,
1271                "copies": mine,
1272            });
1273            match (&a.svg, &a.icon) {
1274                (Some(path), _) => item["svg"] = json!(path),
1275                (None, Some(spec)) => item["icon"] = spec.clone(),
1276                _ => {}
1277            }
1278            assets.push(item);
1279        }
1280    }
1281    Ok(json!({
1282        "size": [size.0, size.1],
1283        "supersample": opts.supersample.max(1),
1284        "sampling": sampling(opts).record(),
1285        "background": background,
1286        "shadow": scene.shadow,
1287        "single": scene.single,
1288        "seed": opts.seed,
1289        "preset": without_kinds(&scene.preset),
1290        "assets": assets,
1291        "poses": poses
1292            .iter()
1293            .map(|p| {
1294                let mut v = json!(p);
1295                if let Some(h) = p.hour {
1296                    v["sun"] = json!(scene.daylight.sun(h));
1297                }
1298                v
1299            })
1300            .collect::<Vec<_>>(),
1301    }))
1302}
1303
1304const GPU_NAME: &str = "pfx";
1305
1306fn trace(class: &str, job: &Value, name: &str, live: &mut Job) -> Result<Value, String> {
1307    let dir = scratch()?;
1308    let job_path = dir.join(format!("{name}.json"));
1309    std::fs::write(
1310        &job_path,
1311        serde_json::to_vec_pretty(job).map_err(|e| e.to_string())?,
1312    )
1313    .map_err(|e| e.to_string())?;
1314    let err_path = dir.join(format!("{name}.err"));
1315    let err = std::fs::File::create(&err_path).map_err(|e| e.to_string())?;
1316    let exe = std::env::current_exe().map_err(|e| format!("pfx: {e}"))?;
1317    let pfx_run::queue::Launch {
1318        command: mut cmd,
1319        queued,
1320    } = pfx_run::queue::launch(class, GPU_NAME, exe);
1321    cmd.args(std::env::args_os().skip(1))
1322        .env(render::JOB_VAR, &job_path)
1323        .stderr(err);
1324    let result = drive(&mut cmd, &err_path, Some(live), queued)?;
1325    let ignored: Vec<&str> = result
1326        .pointer("/tracer/ignored")
1327        .and_then(Value::as_array)
1328        .map(|list| list.iter().filter_map(Value::as_str).collect())
1329        .unwrap_or_default();
1330    if !ignored.is_empty() {
1331        eprintln!(
1332            "{TOOL}: the tracer does not draw these preset keys yet: {}",
1333            ignored.join(", ")
1334        );
1335    }
1336    if let Some(device) = result.get("device").and_then(Value::as_str) {
1337        live.device(device);
1338    }
1339    Ok(result)
1340}
1341
1342fn drive(
1343    cmd: &mut Command,
1344    err_path: &Path,
1345    live: Option<&mut Job>,
1346    queued: bool,
1347) -> Result<Value, String> {
1348    let mut child = cmd
1349        .stdin(Stdio::piped())
1350        .stdout(Stdio::piped())
1351        .spawn()
1352        .map_err(|e| format!("pgpu: {e}"))?;
1353    let tracked = pfx_run::ending::track(&child);
1354    if queued && let Some(live) = live.as_deref() {
1355        live.pgpu(Some(child.id()));
1356    }
1357    let mut tail: Vec<String> = Vec::new();
1358    let mut result = None;
1359    let stdout = child.stdout.take().ok_or("the trace has no output")?;
1360    for line in BufReader::new(stdout).lines().map_while(Result::ok) {
1361        if let Some(rest) = line.strip_prefix("RESULT ") {
1362            result = Some(rest.to_string());
1363            if queued {
1364                pfx_run::ending::below(child.id())
1365                    .iter()
1366                    .for_each(Process::end);
1367            } else {
1368                pfx_run::ending::terminate(&mut child);
1369            }
1370            drop(child.stdin.take());
1371        } else if let Some(rest) = line.strip_prefix("FRAME ") {
1372            if let Some(done) = rest.split('/').next().and_then(|n| n.parse().ok())
1373                && let Some(live) = live.as_deref()
1374            {
1375                live.progress(done);
1376            }
1377            eprint!("\rframe {rest}   ");
1378        } else {
1379            tail.push(line);
1380            if tail.len() > 12 {
1381                tail.remove(0);
1382            }
1383        }
1384    }
1385    let status = child.wait();
1386    drop(tracked);
1387    if queued && let Some(live) = live.as_deref() {
1388        live.pgpu(None);
1389    }
1390    let status = status.map_err(|e| e.to_string())?;
1391    let line = result.ok_or_else(|| {
1392        let errors = std::fs::read_to_string(err_path).unwrap_or_default();
1393        let last: Vec<&str> = errors.lines().rev().take(12).collect();
1394        format!(
1395            "the trace gave no result ({status}):\n{}\n{}",
1396            tail.join("\n"),
1397            last.into_iter().rev().collect::<Vec<_>>().join("\n")
1398        )
1399    })?;
1400    serde_json::from_str(&line).map_err(|e| e.to_string())
1401}
1402
1403fn linear(image: &image::DynamicImage) -> image::Rgba32FImage {
1404    let mut out = image.to_rgba32f();
1405    for p in out.pixels_mut() {
1406        let a = p[3];
1407        for c in 0..3 {
1408            p[c] = linear_channel(p[c]) * a;
1409        }
1410    }
1411    out
1412}
1413
1414fn resized(image: &image::Rgba32FImage, w: u32, h: u32) -> image::Rgba32FImage {
1415    if image.width() == w && image.height() == h {
1416        return image.clone();
1417    }
1418    image::imageops::resize(image, w, h, image::imageops::FilterType::Lanczos3)
1419}
1420
1421fn save_png(path: &Path, image: &image::Rgba32FImage, sixteen: bool) -> Result<(), String> {
1422    let file = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;
1423    let mut encoder =
1424        png::Encoder::new(std::io::BufWriter::new(file), image.width(), image.height());
1425    encoder.set_color(png::ColorType::Rgba);
1426    encoder.set_depth(if sixteen {
1427        png::BitDepth::Sixteen
1428    } else {
1429        png::BitDepth::Eight
1430    });
1431    encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1432    let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1433    let mut bytes =
1434        Vec::with_capacity((image.width() * image.height() * if sixteen { 8 } else { 4 }) as usize);
1435    for (x, y, p) in image.enumerate_pixels() {
1436        let a = p[3].clamp(0.0, 1.0);
1437        let dither = if sixteen {
1438            0.0
1439        } else {
1440            (pfx_post::hash::bayer(x, y) - 0.5) / 255.0
1441        };
1442        let mut px = [0.0f32; 4];
1443        for c in 0..3 {
1444            let straight = if a > 1e-6 { p[c] / a } else { 0.0 };
1445            px[c] = (encode_channel(straight.clamp(0.0, 1.0)) + dither).clamp(0.0, 1.0);
1446        }
1447        px[3] = a;
1448        for v in px {
1449            if sixteen {
1450                bytes.extend_from_slice(&((v * 65535.0 + 0.5) as u16).to_be_bytes());
1451            } else {
1452                bytes.push((v * 255.0 + 0.5) as u8);
1453            }
1454        }
1455    }
1456    writer.write_image_data(&bytes).map_err(|e| e.to_string())
1457}
1458
1459fn finish(
1460    opts: &Opts,
1461    raw: &Path,
1462    final_path: &Path,
1463    size: (u32, u32),
1464) -> Result<Vec<PathBuf>, String> {
1465    let image = image::open(raw).map_err(|e| format!("{}: {e}", raw.display()))?;
1466    let full = resized(&linear(&image), size.0, size.1);
1467    let stem = final_path
1468        .file_stem()
1469        .and_then(|s| s.to_str())
1470        .unwrap_or("render")
1471        .to_string();
1472    let master = final_path.with_file_name(format!("{stem}.master.png"));
1473    save_png(&master, &full, true)?;
1474    save_png(final_path, &full, false)?;
1475    let mut made = vec![final_path.to_path_buf(), master];
1476    let base = stem
1477        .rsplit_once('-')
1478        .map(|(b, _)| b.to_string())
1479        .unwrap_or(stem.clone());
1480    for text in &opts.derive {
1481        let px: u32 = text
1482            .parse()
1483            .map_err(|_| format!("a still's --derive takes widths; got {text}"))?;
1484        let h = (px as f64 * size.1 as f64 / size.0 as f64).round().max(1.0) as u32;
1485        let path = final_path.with_file_name(format!("{base}-{px}x{h}.png"));
1486        save_png(&path, &resized(&full, px, h), false)?;
1487        made.push(path);
1488    }
1489    for px in &opts.web {
1490        for (scale, suffix) in [(1, ""), (2, "@2x")] {
1491            let w = px * scale;
1492            let h = (w as f64 * size.1 as f64 / size.0 as f64).round().max(1.0) as u32;
1493            let path = final_path.with_file_name(format!("{stem}-web-{px}{suffix}.png"));
1494            save_png(&path, &resized(&full, w, h), false)?;
1495            made.push(path);
1496        }
1497    }
1498    Ok(made)
1499}
1500
1501fn file_entry(path: &Path) -> Value {
1502    let bytes = std::fs::read(path).unwrap_or_default();
1503    json!({"file": path.file_name().and_then(|n| n.to_str()), "sha256": sha(&bytes), "bytes": bytes.len()})
1504}
1505
1506fn assets_record(scene: &Scene) -> Value {
1507    let list: Vec<Value> = scene
1508        .entries
1509        .iter()
1510        .flat_map(|e| &e.assets)
1511        .map(|a| {
1512            let (path, digest) = match &a.svg {
1513                Some(p) => (
1514                    json!(p.display().to_string()),
1515                    std::fs::read(p)
1516                        .map(|b| json!(sha(&b)))
1517                        .unwrap_or(Value::Null),
1518                ),
1519                None => (Value::Null, Value::Null),
1520            };
1521            json!({
1522                "use": a.picked_by,
1523                "kind": if a.kind == "icons" { "icon" } else { a.kind.as_str() },
1524                "product": a.product,
1525                "name": a.name,
1526                "path": path,
1527                "sha256": digest,
1528                "preset_sha256": sha(a.preset_text.as_bytes()),
1529                "recipe": a.recipe,
1530                "icon_set": a.icon_set,
1531            })
1532        })
1533        .collect();
1534    json!(list)
1535}
1536
1537fn params(opts: &Opts, scene: &Scene, size: (u32, u32), background: &str) -> Value {
1538    let entries: Vec<Value> = scene
1539        .entries
1540        .iter()
1541        .map(|e| json!({"instances": e.inst, "at": e.at, "turn": e.turn, "tilt": e.tilt, "assets": e.assets.len()}))
1542        .collect();
1543    json!({
1544        "size": [size.0, size.1],
1545        "derive": opts.derive,
1546        "web": opts.web,
1547        "supersample": opts.supersample.max(1),
1548        "sampling": sampling(opts).record(),
1549        "draft": opts.draft,
1550        "background": background,
1551        "shadow": scene.shadow,
1552        "seed": opts.seed,
1553        "set": opts.sets,
1554        "camera": scene.camera,
1555        "keys": scene.keys,
1556        "entries": entries,
1557        "daylight": scene.daylight,
1558        "class": opts.class,
1559    })
1560}
1561
1562fn pinned(opts: &Opts) -> bool {
1563    opts.uses.is_empty() && std::env::current_exe().is_ok_and(|exe| installed(&exe))
1564}
1565
1566fn installed(exe: &Path) -> bool {
1567    !exe.components().any(|part| part.as_os_str() == "target")
1568}
1569
1570fn asset_version(shot_sha: Option<&str>, preset_sha: Option<&str>) -> String {
1571    shot_sha
1572        .or(preset_sha)
1573        .unwrap_or("unknown")
1574        .chars()
1575        .take(12)
1576        .collect()
1577}
1578
1579fn manifest(
1580    scene: &Scene,
1581    kind: &str,
1582    result: &Value,
1583    params: Value,
1584    outputs: Value,
1585    files: Vec<Value>,
1586) -> Value {
1587    let mut m = json!({
1588        "tool": {"name": TOOL, "version": env!("CARGO_PKG_VERSION")},
1589        "product": scene.product,
1590        "kind": kind,
1591        "source": scene.source,
1592        "params": params,
1593        "device": result.get("device"),
1594        "colour": result.get("colour"),
1595        "outputs": outputs,
1596        "seconds": result.get("seconds"),
1597        "files": files,
1598        "tracer": result.get("tracer"),
1599        "preset_sha256": scene.preset_sha,
1600        "recipes": scene.recipes,
1601        "version": asset_version(scene.shot.as_ref().map(|(_, digest)| digest.as_str()), scene.preset_sha.as_deref()),
1602    });
1603    if let Some((name, digest)) = &scene.shot {
1604        m["shot"] = json!(name);
1605        m["shot_sha256"] = json!(digest);
1606        m["assets"] = assets_record(scene);
1607    } else if !scene.single {
1608        m["assets"] = assets_record(scene);
1609    }
1610    if scene.shot.is_none() {
1611        m["shot"] = json!(
1612            scene
1613                .base
1614                .strip_prefix(&format!("{}-", scene.product))
1615                .unwrap_or(&scene.base)
1616        );
1617    }
1618    m
1619}
1620
1621fn write_manifest(path: &Path, manifest: &Value) -> Result<(), String> {
1622    std::fs::write(
1623        path,
1624        serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())?,
1625    )
1626    .map_err(|e| e.to_string())
1627}
1628
1629struct Still {
1630    job: Value,
1631    size: (u32, u32),
1632    name: String,
1633    background: String,
1634}
1635
1636fn still_job(opts: &Opts, scene: &Scene) -> Result<Still, String> {
1637    let size = size_of(opts.size.as_deref().unwrap_or(if scene.shot.is_some() {
1638        "1920x1080"
1639    } else {
1640        "2048"
1641    }))?;
1642    let background = opts
1643        .background
1644        .clone()
1645        .unwrap_or_else(|| "transparent".into());
1646    let pose = motion::pose_at(&scene.camera, &scene.keys, opts.at.unwrap_or(0.0));
1647    let name = format!("{}-{}x{}", scene.base, size.0, size.1);
1648    let job = job(opts, scene, size, &background, &[pose])?;
1649    Ok(Still {
1650        job,
1651        size,
1652        name,
1653        background,
1654    })
1655}
1656
1657fn still(opts: &Opts, scene: &Scene, live: &mut Job) -> Result<(), String> {
1658    let made_at = pfx_run::archive::local_made();
1659    let Still {
1660        mut job,
1661        size,
1662        name,
1663        background,
1664    } = still_job(opts, scene)?;
1665    let dir = scratch()?;
1666    let raw = dir.join(format!("{name}-raw.png"));
1667    job["out"] = json!(raw);
1668    let result = trace(&opts.class, &job, &name, live)?;
1669    live.rendered();
1670    let out_dir = opts.out.join(&scene.product).join(&scene.folder);
1671    std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
1672    live.out(&out_dir);
1673    let final_path = out_dir.join(format!("{name}.png"));
1674    let made = finish(opts, &raw, &final_path, size)?;
1675    let files: Vec<Value> = made.iter().map(|p| file_entry(p)).collect();
1676    let mut p = params(opts, scene, size, &background);
1677    p["at"] = json!(opts.at.unwrap_or(0.0));
1678    let outputs = json!({"delivery": "8-bit sRGB PNG with an sRGB chunk", "master": "16-bit sRGB PNG with an sRGB chunk", "resampling": "Lanczos3 in linear light, premultiplied"});
1679    let kind = if scene.shot.is_some() {
1680        "still"
1681    } else {
1682        scene.folder.as_str()
1683    };
1684    let mut m = manifest(scene, kind, &result, p, outputs, files);
1685    m["made"] = json!(made_at);
1686    m["pinned"] = json!(pinned(opts) && recipes_pinned(scene));
1687    let manifest_path = out_dir.join(format!("{name}.json"));
1688    write_manifest(&manifest_path, &m)?;
1689    live.archiving(pfx_run::archive::reason(&manifest_path, opts.no_archive).is_none());
1690    if pfx_run::archive::archive(&manifest_path, opts.no_archive) == pfx_run::archive::State::Queued
1691    {
1692        live.note("archive queued: share offline");
1693    }
1694    println!("{}", final_path.display());
1695    Ok(())
1696}
1697
1698fn frame_bytes(image: &image::Rgba32FImage) -> Vec<u8> {
1699    let mut bytes = Vec::with_capacity((image.width() * image.height() * 8) as usize);
1700    for p in image.pixels() {
1701        for c in 0..3 {
1702            let v = encode_channel(p[c].clamp(0.0, 1.0));
1703            bytes.extend_from_slice(&((v * 65535.0 + 0.5) as u16).to_le_bytes());
1704        }
1705        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
1706    }
1707    bytes
1708}
1709
1710fn opaque(image: &image::Rgba32FImage) -> image::Rgba32FImage {
1711    let mut out = image.clone();
1712    for p in out.pixels_mut() {
1713        p[3] = 1.0;
1714    }
1715    out
1716}
1717
1718fn blend(a: &image::Rgba32FImage, b: &image::Rgba32FImage, w: f32) -> image::Rgba32FImage {
1719    let mut out = a.clone();
1720    for (o, q) in out.pixels_mut().zip(b.pixels()) {
1721        for c in 0..4 {
1722            o[c] = o[c] * (1.0 - w) + q[c] * w;
1723        }
1724    }
1725    out
1726}
1727
1728fn order(closure: &Closure, n: usize, fade: usize) -> Vec<(usize, Option<(usize, f32)>)> {
1729    match closure {
1730        Closure::Open => (0..n).map(|i| (i, None)).collect(),
1731        Closure::Palindrome => (0..n)
1732            .chain((1..n.saturating_sub(1)).rev())
1733            .map(|i| (i, None))
1734            .collect(),
1735        Closure::Crossfade(_) => (0..n)
1736            .map(|j| {
1737                if j < fade {
1738                    (n + j, Some((j, j as f32 / fade as f32)))
1739                } else {
1740                    (j, None)
1741                }
1742            })
1743            .collect(),
1744    }
1745}
1746
1747fn clip(opts: &Opts, scene: &Scene, live: &mut Job) -> Result<(), String> {
1748    let made_at = pfx_run::archive::local_made();
1749    if !opts.derive.is_empty() {
1750        return Err("clip --derive belongs to pconv; use pconv for more video sizes".into());
1751    }
1752    if opts.fps != video::FPS {
1753        return Err(format!("{TOOL} renders at {}", video::FPS));
1754    }
1755    let seconds = scene.seconds.ok_or(
1756        "a clip needs seconds: set seconds in the shot, pass --seconds, or end with a key",
1757    )?;
1758    let size = size_of(opts.size.as_deref().unwrap_or("1080p"))?;
1759    if size.0 % 2 != 0 || size.1 % 2 != 0 {
1760        return Err("a clip's size must be even in both directions".into());
1761    }
1762    let fps = opts.fps;
1763    let n = motion::frames(seconds, fps);
1764    let fade = match scene.closure {
1765        Closure::Crossfade(s) => {
1766            let f = motion::frames(s, fps);
1767            if f >= n {
1768                return Err("the crossfade must be shorter than the clip".into());
1769            }
1770            f
1771        }
1772        _ => 0,
1773    };
1774    let rendered = n + fade;
1775    let poses: Vec<Pose> = (0..rendered)
1776        .map(|i| motion::pose_at(&scene.camera, &scene.keys, i as f64 / fps as f64))
1777        .collect();
1778    let background = match &opts.background {
1779        Some(b) if b == "transparent" => {
1780            return Err("a clip needs an opaque --background: #rrggbb or hdri".into());
1781        }
1782        Some(b) => b.clone(),
1783        None => scene
1784            .preset
1785            .get("ground")
1786            .and_then(|g| g.get("color"))
1787            .and_then(Value::as_str)
1788            .unwrap_or("#24282C")
1789            .to_string(),
1790    };
1791    let stem = format!("{}-{}x{}-{fps}fps", scene.base, size.0, size.1);
1792    let dir = scratch()?;
1793    let frames_dir = dir.join(format!("{stem}-frames"));
1794    if frames_dir.exists() {
1795        std::fs::remove_dir_all(&frames_dir).map_err(|e| e.to_string())?;
1796    }
1797    std::fs::create_dir_all(&frames_dir).map_err(|e| e.to_string())?;
1798    let mut job = job(opts, scene, size, &background, &poses)?;
1799    job["frames"] = json!(frames_dir);
1800    job["fps"] = json!(fps);
1801    live.stage(stages::TRACE, Some(rendered as u64), "frames");
1802    let result = trace(&opts.class, &job, &stem, live)?;
1803    eprintln!();
1804    let out_dir = opts.out.join(&scene.product).join("clip");
1805    std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
1806    live.out(&out_dir);
1807    let master = out_dir.join(format!("{stem}.master.mp4"));
1808    let poster = out_dir.join(format!("{stem}.poster.png"));
1809    let mut pipe = if opts.no_encode {
1810        None
1811    } else {
1812        Some(video::Pipe::open(size.0, size.1, fps, &master)?)
1813    };
1814    let loose = if opts.no_encode {
1815        let path = out_dir.join(format!("{stem}.frames"));
1816        std::fs::create_dir_all(&path).map_err(|e| e.to_string())?;
1817        Some(path)
1818    } else {
1819        None
1820    };
1821    let load = |i: usize| -> Result<image::Rgba32FImage, String> {
1822        let path = frames_dir.join(format!("{i:05}.png"));
1823        let image = image::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
1824        Ok(opaque(&resized(&linear(&image), size.0, size.1)))
1825    };
1826    let sequence = order(&scene.closure, n, fade);
1827    let total = sequence.len();
1828    live.stage(stages::MASTER, Some(total as u64), "frames");
1829    let mut made = vec![poster.clone()];
1830    let mut files = Vec::new();
1831    for (k, (i, mix)) in sequence.into_iter().enumerate() {
1832        let mut frame = load(i)?;
1833        if let Some((j, weight)) = mix {
1834            frame = blend(&frame, &load(j)?, weight);
1835        }
1836        if k == 0 {
1837            save_png(&poster, &frame, false)?;
1838            files.push(file_entry(&poster));
1839        }
1840        if let Some(path) = &loose {
1841            let frame_path = path.join(format!("{k:05}.png"));
1842            save_png(&frame_path, &frame, true)?;
1843            files.push(json!({"file": format!("{stem}.frames/{k:05}.png"), "sha256": sha(&std::fs::read(&frame_path).map_err(|e| e.to_string())?), "bytes": std::fs::metadata(&frame_path).map_err(|e| e.to_string())?.len()}));
1844            made.push(frame_path);
1845        }
1846        if let Some(encoder) = &mut pipe {
1847            encoder.send(&frame_bytes(&frame))?;
1848        }
1849        live.progress(k as u64 + 1);
1850    }
1851    if let Some(encoder) = pipe {
1852        encoder.finish()?;
1853        live.stage(stages::ENCODE, Some(1), "outputs");
1854        let mut entry = file_entry(&master);
1855        entry["tier"] = json!("master");
1856        entry["fps"] = json!(fps);
1857        entry["args"] = json!(pfx_run::encode::master_args(fps));
1858        entry["depth"] = json!(8);
1859        entry["chroma"] = json!("4:2:0");
1860        entry["color"] = json!(pfx_run::encode::COLOR);
1861        files.push(entry);
1862        made.push(master);
1863        live.progress(1);
1864    }
1865    let mut params = params(opts, scene, size, &background);
1866    params["fps"] = json!(fps);
1867    params["seconds"] = json!(seconds);
1868    params["frames"] = json!(total);
1869    params["closure"] = scene.closure.label();
1870    let outputs = json!({
1871        "master": "x264 CRF 12, High, yuv420p, BT.709 limited",
1872        "poster": "the first frame, 8-bit sRGB PNG",
1873        "resampling": "Lanczos3 in linear light, premultiplied",
1874    });
1875    let mut manifest = manifest(scene, "clip", &result, params, outputs, files);
1876    manifest["made"] = json!(made_at);
1877    manifest["pinned"] = json!(pinned(opts) && recipes_pinned(scene));
1878    manifest["ffmpeg"] = pfx_run::ffmpeg::system()?.record();
1879    let manifest_path = out_dir.join(format!("{stem}.json"));
1880    write_manifest(&manifest_path, &manifest)?;
1881    if !opts.keep_frames && !opts.no_encode {
1882        std::fs::remove_dir_all(&frames_dir).map_err(|e| e.to_string())?;
1883    }
1884    live.archiving(pfx_run::archive::reason(&manifest_path, opts.no_archive).is_none());
1885    if pfx_run::archive::archive(&manifest_path, opts.no_archive) == pfx_run::archive::State::Queued
1886    {
1887        live.note("archive queued: share offline");
1888    }
1889    println!("{}", manifest_path.display());
1890    Ok(())
1891}
1892
1893fn reencode(manifest_path: &Path, live: &mut Job) -> Result<(), String> {
1894    let text = std::fs::read_to_string(manifest_path)
1895        .map_err(|e| format!("{}: {e}", manifest_path.display()))?;
1896    let mut manifest: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
1897    if manifest["tool"]["name"].as_str() != Some(TOOL) || manifest["kind"].as_str() != Some("clip")
1898    {
1899        return Err(format!(
1900            "{} is not a pfx asset clip manifest",
1901            manifest_path.display()
1902        ));
1903    }
1904    let dir = manifest_path.parent().ok_or("the manifest has no folder")?;
1905    let old = manifest["files"]
1906        .as_array()
1907        .and_then(|files| {
1908            files
1909                .iter()
1910                .filter_map(|f| f["file"].as_str())
1911                .find(|name| name.ends_with(".master.mkv"))
1912        })
1913        .ok_or("the manifest lists no .master.mkv")?
1914        .to_string();
1915    let stem = old.trim_end_matches(".master.mkv");
1916    let old_path = dir.join(&old);
1917    if !old_path.is_file() {
1918        return Err(format!("pfx encode needs {}", old_path.display()));
1919    }
1920    let fps = manifest["params"]["fps"]
1921        .as_u64()
1922        .unwrap_or(video::FPS as u64) as u32;
1923    let master = dir.join(format!("{stem}.master.mp4"));
1924    live.out(dir);
1925    live.stage(stages::ENCODE, Some(1), "outputs");
1926    video::from_file(&old_path, fps, &master)?;
1927    let mut files: Vec<Value> = manifest["files"]
1928        .as_array()
1929        .cloned()
1930        .unwrap_or_default()
1931        .into_iter()
1932        .filter(|f| {
1933            let name = f["file"].as_str().unwrap_or_default();
1934            !name.ends_with(".master.mkv")
1935                && !name.ends_with(".hevc.mp4")
1936                && !name.ends_with(".youtube.mp4")
1937                && !name.ends_with(".av1.mp4")
1938                && !name.ends_with(".h264.mp4")
1939        })
1940        .collect();
1941    let mut entry = file_entry(&master);
1942    entry["tier"] = json!("master");
1943    entry["fps"] = json!(fps);
1944    entry["args"] = json!(pfx_run::encode::master_args(fps));
1945    entry["depth"] = json!(8);
1946    entry["chroma"] = json!("4:2:0");
1947    entry["color"] = json!(pfx_run::encode::COLOR);
1948    files.push(entry);
1949    manifest["files"] = json!(files);
1950    manifest["outputs"]["master"] = json!("x264 CRF 12, High, yuv420p, BT.709 limited");
1951    if manifest["version"].is_null() {
1952        let digest = manifest["shot_sha256"]
1953            .as_str()
1954            .or_else(|| manifest["preset_sha256"].as_str())
1955            .unwrap_or("unknown");
1956        manifest["version"] = json!(digest.chars().take(12).collect::<String>());
1957    }
1958    manifest["ffmpeg"] = pfx_run::ffmpeg::system()?.record();
1959    write_manifest(manifest_path, &manifest)?;
1960    std::fs::remove_file(old_path).map_err(|e| e.to_string())?;
1961    live.progress(1);
1962    live.archiving(pfx_run::archive::reason(manifest_path, false).is_none());
1963    if pfx_run::archive::archive(manifest_path, false) == pfx_run::archive::State::Queued {
1964        live.note("archive queued: share offline");
1965    }
1966    println!("{}", manifest_path.display());
1967    Ok(())
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972    use super::*;
1973
1974    #[test]
1975    fn asset_versions_take_twelve_hex_digits_from_the_shot_or_preset() {
1976        assert_eq!(
1977            asset_version(Some("0123456789abcdef"), Some("fedcba9876543210")),
1978            "0123456789ab"
1979        );
1980        assert_eq!(
1981            asset_version(None, Some("fedcba9876543210")),
1982            "fedcba987654"
1983        );
1984    }
1985
1986    #[test]
1987    fn sizes_read_squares_pairs_and_video_names() {
1988        assert_eq!(size_of("2048").unwrap(), (2048, 2048));
1989        assert_eq!(size_of("1200x630").unwrap(), (1200, 630));
1990        assert_eq!(size_of("2160p").unwrap(), (3840, 2160));
1991        assert!(size_of("0").is_err());
1992    }
1993
1994    #[test]
1995    fn palindromes_do_not_repeat_their_ends_and_crossfades_blend_the_tail_in() {
1996        let p: Vec<usize> = order(&Closure::Palindrome, 4, 0)
1997            .into_iter()
1998            .map(|(i, _)| i)
1999            .collect();
2000        assert_eq!(p, [0, 1, 2, 3, 2, 1]);
2001        let f = order(&Closure::Crossfade(0.5), 6, 2);
2002        assert_eq!(f[0], (6, Some((0, 0.0))));
2003        assert_eq!(f[1], (7, Some((1, 0.5))));
2004        assert_eq!(f[2], (2, None));
2005        assert_eq!(f.len(), 6);
2006    }
2007
2008    #[test]
2009    fn kind_tables_override_their_sections_for_that_kind_only() {
2010        let mut preset = json!({
2011            "form": {"depth": 0.12, "voxel": 0.06},
2012            "kind": {"lockup": {"form": {"voxel": 0.012}, "camera": {"margin": 1.3}}}
2013        });
2014        let mut logo = preset.clone();
2015        for_kind(&mut logo, "logo").unwrap();
2016        assert_eq!(logo["form"]["voxel"], json!(0.06));
2017        for_kind(&mut preset, "lockup").unwrap();
2018        assert_eq!(preset["form"], json!({"depth": 0.12, "voxel": 0.012}));
2019        assert_eq!(preset["camera"]["margin"], json!(1.3));
2020        assert!(without_kinds(&preset).get("kind").is_none());
2021        let mut bad = json!({"kind": {"lockup": {"shape": {}}}});
2022        assert!(for_kind(&mut bad, "lockup").is_err());
2023    }
2024
2025    #[test]
2026    fn set_reaches_nested_keys_and_parses_json() {
2027        let mut v = json!({"camera": {"turn": 1}});
2028        apply_sets(
2029            &mut v,
2030            &[
2031                "camera.turn=30".into(),
2032                "rig.strength=0.5".into(),
2033                "line=hello".into(),
2034            ],
2035        )
2036        .unwrap();
2037        assert_eq!(v["camera"]["turn"], json!(30));
2038        assert_eq!(v["rig"]["strength"], json!(0.5));
2039        assert_eq!(v["line"], json!("hello"));
2040    }
2041
2042    fn fixtures() -> PathBuf {
2043        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/recipes")
2044    }
2045
2046    fn opts_of(args: &[&str]) -> Opts {
2047        let cli = Cli::try_parse_from([&["pfx"], args].concat()).unwrap();
2048        match cli.command {
2049            Kind::Still { opts, .. } | Kind::Clip { opts, .. } => opts,
2050            _ => panic!("expected a shot"),
2051        }
2052    }
2053
2054    #[test]
2055    fn the_sample_recipes_are_valid() {
2056        let recipes = Recipes::new(Some(recipes::Root {
2057            dir: fixtures(),
2058            origin: recipes::Origin::Flag,
2059        }));
2060        for product in recipes.books() {
2061            let rel = format!("shots/{product}.toml");
2062            let all = toml_json(&recipes.read(&rel).unwrap().text, &rel).unwrap();
2063            for (name, shot) in all["shots"].as_object().unwrap() {
2064                shots::check_shot(name, shot.as_object().unwrap()).unwrap();
2065                for k in shot
2066                    .get("keys")
2067                    .and_then(Value::as_array)
2068                    .into_iter()
2069                    .flatten()
2070                {
2071                    Key::from_table(k.as_object().unwrap(), name).unwrap();
2072                }
2073                Closure::read(shot.get("closure")).unwrap();
2074            }
2075        }
2076        for product in recipes.presets() {
2077            preset_recipe(&recipes, &product, None).unwrap();
2078        }
2079        assert_eq!(
2080            icon_names(&recipes, &["sample".to_string()]).unwrap(),
2081            [
2082                ("sample".into(), "shapes".into(), "dot".into()),
2083                ("sample".into(), "shapes".into(), "ring".into())
2084            ]
2085        );
2086    }
2087
2088    #[test]
2089    fn a_sample_shot_resolves_from_the_recipes_folder_and_records_each_recipe() {
2090        let root = fixtures().display().to_string();
2091        let opts = opts_of(&["still", "sample", "shapes", "--recipes", &root]);
2092        let recipes = recipes_of(&opts.recipes).unwrap();
2093        let scene = shot_scene(&opts, &recipes, "sample", "shapes", "still").unwrap();
2094        assert_eq!(scene.entries.len(), 1);
2095        let names: Vec<_> = scene.entries[0]
2096            .assets
2097            .iter()
2098            .map(|a| a.name.clone().unwrap())
2099            .collect();
2100        assert_eq!(names, ["shapes/dot", "shapes/ring"]);
2101        let paths: Vec<&str> = scene
2102            .recipes
2103            .iter()
2104            .map(|r| r["file"].as_str().unwrap())
2105            .collect();
2106        for rel in [
2107            "shots/sample.toml",
2108            "presets/sample.toml",
2109            "icons/sample/shapes.toml",
2110        ] {
2111            let file = fixtures().join(rel).display().to_string();
2112            assert!(paths.contains(&file.as_str()), "{rel} in {paths:?}");
2113        }
2114        assert_eq!(scene.recipes.len(), 3);
2115        let record = &assets_record(&scene)[0];
2116        assert_eq!(record["recipe"]["sha256"], record["preset_sha256"]);
2117        assert!(
2118            record["icon_set"]["file"]
2119                .as_str()
2120                .unwrap()
2121                .ends_with("shapes.toml")
2122        );
2123        assert!(pfx_run::archive::SAMPLES.contains(&scene.product.as_str()));
2124        let missing = shot_scene(&opts, &recipes, "sample", "nothing", "still")
2125            .err()
2126            .unwrap();
2127        assert!(missing.contains("no shot nothing"), "{missing}");
2128        let nobody = opts_of(&["still", "nobody", "x", "--recipes", &root]);
2129        let error = shot_scene(&nobody, &recipes, "nobody", "x", "still")
2130            .err()
2131            .unwrap();
2132        assert_eq!(
2133            error,
2134            format!(
2135                "no recipe at {}",
2136                fixtures().join("shots/nobody.toml").display()
2137            )
2138        );
2139    }
2140
2141    #[test]
2142    fn a_shot_across_products_reads_each_from_the_callers_own_folder_in_its_order() {
2143        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2144            .join("tmp")
2145            .join(format!("across-{}", std::process::id()));
2146        let own = base.join("own");
2147        for product in ["sample", "other", "third"] {
2148            std::fs::create_dir_all(own.join("presets")).unwrap();
2149            std::fs::create_dir_all(own.join("icons").join(product)).unwrap();
2150            std::fs::write(
2151                own.join("presets").join(format!("{product}.toml")),
2152                format!("[material]\nbase = \"#808080\"\nline = \"{product}\"\n"),
2153            )
2154            .unwrap();
2155            std::fs::write(
2156                own.join("icons").join(product).join("set.toml"),
2157                "[[icon]]\nname = \"dot\"\n\n[[icon.parts]]\nkind = \"ball\"\nat = [0.0, 0.0]\nr = 0.4\n",
2158            )
2159            .unwrap();
2160        }
2161        std::fs::create_dir_all(own.join("shots")).unwrap();
2162        std::fs::write(
2163            own.join("shots/sample.toml"),
2164            r#"[products.third]
2165
2166[products.other]
2167
2168[products.sample]
2169
2170[shots.all]
2171line = "Every product's icons"
2172
2173[[shots.all.assets]]
2174use = "icon:*"
2175"#,
2176        )
2177        .unwrap();
2178        let root = own.display().to_string();
2179        let opts = opts_of(&["still", "sample", "all", "--recipes", &root]);
2180        let recipes = recipes_of(&opts.recipes).unwrap();
2181        let scene = shot_scene(&opts, &recipes, "sample", "all", "still").unwrap();
2182        let products: Vec<&str> = scene.entries[0]
2183            .assets
2184            .iter()
2185            .map(|a| a.product.as_str())
2186            .collect();
2187        assert_eq!(products, ["third", "other", "sample"]);
2188        assert!(scene.recipes.iter().all(|r| {
2189            r["file"]
2190                .as_str()
2191                .is_some_and(|file| file.starts_with(own.to_str().unwrap()))
2192        }));
2193        std::fs::write(
2194            own.join("shots/sample.toml"),
2195            "[products.other]\nrepo = \"file:///repos/other.git\"\nrev = \"v1.0.0\"\n\n[shots.all]\nline = \"x\"\n",
2196        )
2197        .unwrap();
2198        assert!(shot_scene(&opts, &recipes, "sample", "all", "still").is_err());
2199        std::fs::remove_dir_all(&base).ok();
2200    }
2201
2202    #[test]
2203    fn a_master_comes_from_the_marks_folder_and_a_missing_one_names_the_path() {
2204        let marks = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/marks");
2205        let root = fixtures().display().to_string();
2206        let folder = marks.display().to_string();
2207        let cli = Cli::try_parse_from([
2208            "pfx",
2209            "logo",
2210            "sample",
2211            "--recipes",
2212            &root,
2213            "--assets",
2214            &folder,
2215        ])
2216        .unwrap();
2217        let Kind::Logo { opts, .. } = cli.command else {
2218            panic!("expected a logo");
2219        };
2220        let recipes = recipes_of(&opts.recipes).unwrap();
2221        let logo = asset(
2222            &opts,
2223            &recipes,
2224            "logo",
2225            "sample",
2226            None,
2227            "logo:sample",
2228            "sample",
2229            None,
2230        )
2231        .unwrap();
2232        assert_eq!(logo.svg, Some(marks.join("logos/sample.svg")));
2233        let missing = asset(
2234            &opts,
2235            &recipes,
2236            "wordmark",
2237            "absent",
2238            None,
2239            "wordmark:absent",
2240            "sample",
2241            None,
2242        )
2243        .err()
2244        .unwrap();
2245        assert!(
2246            missing.contains(&marks.join("wordmarks/absent.svg").display().to_string())
2247                && missing.contains("--assets")
2248                && missing.contains("PITO_ASSETS")
2249                && missing.contains(MARKS),
2250            "{missing}"
2251        );
2252    }
2253
2254    #[test]
2255    fn every_render_is_one_job_named_after_its_command_and_listings_are_none() {
2256        let job = |args: &[&str]| {
2257            let cli = Cli::try_parse_from([&["pfx"], args].concat()).unwrap();
2258            describe(&cli.command)
2259                .map(|(app, label, single)| (app.map(str::to_string), label, single))
2260        };
2261        assert_eq!(
2262            job(&["logo", "sample"]),
2263            Some((Some("sample".into()), "logo sample".into(), true))
2264        );
2265        assert_eq!(
2266            job(&["icons", "sample", "shapes"]),
2267            Some((Some("sample".into()), "icons sample shapes".into(), false))
2268        );
2269        assert_eq!(
2270            job(&["still", "sample", "shapes"]),
2271            Some((Some("sample".into()), "still sample shapes".into(), true))
2272        );
2273        assert_eq!(
2274            job(&["clip", "sample", "shapes"]),
2275            Some((Some("sample".into()), "clip sample shapes".into(), false))
2276        );
2277        assert_eq!(job(&["presets"]), None);
2278        assert_eq!(job(&["shots"]), None);
2279    }
2280
2281    #[test]
2282    fn a_render_detail_starts_with_the_assets_prefix() {
2283        let cli = Cli::try_parse_from(["pfx", "logo", "sample"]).unwrap();
2284        let (_, label, _) = describe(&cli.command).unwrap();
2285        assert_eq!(format!("{ASSETS_DETAIL}{label}"), "assets: logo sample");
2286        let cli = Cli::try_parse_from(["pfx", "icons", "sample", "shapes"]).unwrap();
2287        let (_, label, _) = describe(&cli.command).unwrap();
2288        assert_eq!(
2289            format!("{ASSETS_DETAIL}{label}"),
2290            "assets: icons sample shapes"
2291        );
2292    }
2293
2294    #[cfg(target_os = "linux")]
2295    #[test]
2296    fn a_trace_is_ended_the_moment_its_result_arrives() {
2297        use std::time::{Duration, Instant};
2298        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2299            .join("tmp")
2300            .join(format!("ending-{}", std::process::id()));
2301        std::fs::create_dir_all(&dir).unwrap();
2302        let err_path = dir.join("stand-in.err");
2303        let mut cmd = Command::new("sh");
2304        cmd.args([
2305            "-c",
2306            r#"sh -c 'echo RESULT {\"out\":1}; exec sleep 60' & wait $!"#,
2307        ])
2308        .stderr(std::fs::File::create(&err_path).unwrap());
2309        let asked = Instant::now();
2310        let result = drive(&mut cmd, &err_path, None, true).unwrap();
2311        assert!(asked.elapsed() < Duration::from_secs(10));
2312        assert_eq!(result, json!({"out": 1}));
2313        std::fs::remove_dir_all(&dir).ok();
2314    }
2315
2316    #[test]
2317    #[ignore = "needs a GPU; run under pgpu"]
2318    fn the_traced_logo_looks_like_its_frozen_reference() {
2319        look_check::check("logo", 1.5, 4.3, 0.97);
2320    }
2321
2322    #[test]
2323    #[ignore = "needs a GPU; run under pgpu"]
2324    fn the_traced_wordmark_looks_like_its_frozen_reference() {
2325        look_check::check("wordmark", 1.7, 4.9, 0.965);
2326    }
2327
2328    #[test]
2329    #[ignore = "needs a GPU; run under pgpu"]
2330    fn the_traced_lockup_looks_like_its_frozen_reference() {
2331        look_check::check("lockup", 1.6, 4.5, 0.97);
2332    }
2333
2334    #[test]
2335    #[ignore = "needs a GPU; run under pgpu"]
2336    fn the_traced_tile_looks_like_its_frozen_reference() {
2337        look_check::check("tile", 0.9, 2.8, 0.975);
2338    }
2339
2340    #[test]
2341    #[ignore = "needs a GPU; run under pgpu"]
2342    fn the_traced_card_looks_like_its_frozen_reference() {
2343        look_check::check("card", 1.2, 3.3, 0.97);
2344    }
2345
2346    #[test]
2347    #[ignore = "needs a GPU; run under pgpu"]
2348    fn the_traced_dot_icon_looks_like_its_frozen_reference() {
2349        look_check::check("icon-shapes-dot", 1.3, 3.4, 0.975);
2350    }
2351
2352    #[test]
2353    #[ignore = "needs a GPU; run under pgpu"]
2354    fn the_traced_ring_icon_looks_like_its_frozen_reference() {
2355        look_check::check("icon-shapes-ring", 1.4, 3.9, 0.975);
2356    }
2357
2358    #[test]
2359    #[ignore = "needs a GPU; run under pgpu"]
2360    fn the_traced_scene_looks_like_its_frozen_reference() {
2361        look_check::check("scene-shapes", 1.1, 3.4, 0.98);
2362    }
2363
2364    #[test]
2365    #[ignore = "needs a GPU; run under pgpu"]
2366    fn the_room_and_the_rig_split_the_light_on_a_mark() {
2367        for name in ["logo", "tile", "icon-shapes-dot"] {
2368            let (frozen, full, rig, room) = look_check::split(name);
2369            eprintln!(
2370                "{name}: frozen {frozen:.4}, tracer {full:.4} = rig {rig:.4} + room {room:.4}; the room's gain to match {:.3}",
2371                (frozen - rig) / room
2372            );
2373        }
2374    }
2375
2376    #[test]
2377    #[ignore = "needs a GPU; run under pgpu"]
2378    fn asset_speed_per_kind() {
2379        eprintln!("kind | final s, GPU ms, samples | draft s, GPU ms, samples");
2380        for (name, _, _) in look_check::REFERENCES {
2381            let f = look_check::speed(name, false);
2382            let d = look_check::speed(name, true);
2383            eprintln!(
2384                "{name} | {:.1}, {:.0}, {:.0} | {:.1}, {:.0}, {:.0}",
2385                f.0, f.1, f.2, d.0, d.1, d.2
2386            );
2387        }
2388    }
2389}