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(§ion.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}