ffcine-cli 0.4.0

ffcine command-line interface
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
//! The footers of the long `--help`: examples per command, and the file formats that otherwise
//! live only in `docs/`.

/// `Examples:` and one indented line per example (`# ...` lines are comments).
macro_rules! examples {
    ($($line:literal),+ $(,)?) => {
        concat!("\n\nExamples:\n", $("\n      ", $line),+)
    };
}

/// A page under `docs/` in the repository.
macro_rules! docs {
    ($page:literal) => {
        concat!(
            "https://github.com/talaagent/ffcine/blob/master/docs/",
            $page
        )
    };
}

/// The `docs/` pages to read next.
macro_rules! see {
    ($($page:literal),+) => {
        concat!("\n\nDocs:\n", $("\n      ", docs!($page)),+)
    };
}

/// A reference section: a title, then its lines as written (indented, unwrapped).
macro_rules! reference {
    ($title:literal, $($line:literal),+ $(,)?) => {
        concat!("\n\n", $title, "\n", $("\n      ", $line),+)
    };
}

pub const TOP: &str = concat!(
    examples!(
        "ffcine doctor                                   # is the GPU path complete?",
        "ffcine ingest --to ~/projects/trip /media/CARD  # verified offload + catalog",
        "ffcine triage ~/projects/trip                   # strips, transcripts, clip page",
        "ffcine grade DJI_0001_D.MP4 graded.mkv         # one graded shot (D-Log auto)",
        "ffcine import edit.fcpxml -o timeline.otio      # another editor's cut",
        "ffcine render --profile master -o master.mp4 timeline.otio",
    ),
    see!("")
);

pub const PROBE: &str = examples!(
    "ffcine probe MVI_2884.MP4                       # Canon EOS R10",
    "ffcine probe DJI_0001_D.MP4                     # DJI Osmo Pocket 4",
    "ffcine probe photo.heic",
);

pub const GRADE: &str = concat!(
    examples!(
        "# Osmo Pocket 4 D-Log: camera LUT from the probed profile (DJI's after fetch-luts, else analytic)",
        "ffcine grade DJI_0001_D.MP4 graded.mkv",
        "# EOS R10 HDR PQ clip: tone-mapped to Rec.709 from the probe, 4K, light sharpening",
        "ffcine grade --size 3840x2160 --sharpen 0.6 MVI_3001.MP4 graded.mkv",
        "# Pocket 4 D-Log through a baked show LUT (camera transform included)",
        "ffcine grade --lut show_dlog.cube DJI_0001_D.MP4 graded.mkv",
        "# 5 s from 12.5 s at 23.976 fps, as a small 8-bit preview",
        "ffcine grade --ss 12.5 --frames 120 --preview MVI_2884.MP4 preview.mp4",
        "# Handheld Pocket 4 night shot: stabilized, night denoise, grain, reframed to a 9:16 reel",
        "ffcine grade --stabilize --denoise-preset night --grain 2 --render-profile reel DJI_0001_D.MP4 reel.mkv",
        "# Phone delivery with the timeline's titles over it",
        "ffcine grade --deliver phone --text timeline.otio --text-at 30 MVI_2884.MP4 phone.mp4",
        "# HDR10 master of an R10 HDR PQ clip (BT.2020 PQ, 1000 cd/m² mastering metadata)",
        "ffcine grade --hdr pq MVI_3001.MP4 hdr10.mkv",
        "# HLG master of a Pocket 4 D-Log clip, through the analytic HDR camera transform",
        "ffcine grade --hdr hlg DJI_0001_D.MP4 hlg.mkv",
    ),
    see!(
        "color.md",
        "denoise.md",
        "stabilize.md",
        "reframe.md",
        "hooks.md",
        "delivery.md#render-profiles"
    )
);

pub const BATCH: &str = concat!(
    reference!(
        "Batch file:",
        "One shot per line, written as `ffcine grade` arguments (flags, then INPUT OUTPUT).",
        "Words split on whitespace; '...' and \"...\" quote; # starts a comment; blank lines are skipped.",
        "Every line is checked before the first shot renders; errors name FILE:LINE.",
        "--cache-dir and --no-cache apply to the whole batch, from the command line.",
    ),
    examples!(
        "ffcine batch shots.txt",
        "# shots.txt:",
        "--lut show_dlog.cube --size 3840x2160 DJI_0001_D.MP4 DJI_0001_graded.mkv",
        "--ss 4 --frames 96 'card 2/MVI_2891.MP4' MVI_2891_graded.mkv   # a 4 s pickup",
        "--preview --camera dlog-vivid DJI_0007_D.MP4 DJI_0007_preview.mp4",
    ),
);

pub const STABILIZE: &str = concat!(
    examples!(
        "# 10 s at 23.976 fps: the warps as a shader for ffmpeg's libplacebo filter",
        "ffcine stabilize --frames 240 --hook warp.hook MVI_2884.MP4",
        "# Stabilize while grading instead",
        "ffcine grade --stabilize --frames 240 MVI_2884.MP4 stable.mkv",
        "# Pocket 4 walking shot: stronger smoothing, zoom capped at 1.1, warps as text",
        "ffcine stabilize --start 30 --frames 480 --smoothing 40 --max-zoom 1.1 --transforms warps.txt DJI_0001_D.MP4",
    ),
    see!("stabilize.md")
);

pub const JITTER: &str = examples!(
    "ffcine jitter --frames 240 stable.mkv",
    "# Skip the pair across a cut at frame 120",
    "ffcine jitter --frames 240 --exclude 120 --rate 30000/1001 stable.mp4",
);

pub const FETCH_LUTS: &str = examples!(
    "ffcine fetch-luts",
    "ffcine fetch-luts --dir ~/luts --force",
);

pub const BAKE: &str = concat!(
    examples!(
        "# Osmo Pocket 4 D-Log -> Rec.709 with a show LUT and a warmer look, as one 65^3 LUT",
        "ffcine bake --camera dlog --lut show.cube --warmth 0.03 --saturation 1.1 baked.cube",
        "# Pocket 4 D-Log -> Rec.709 with half a stop more exposure, a 33^3 LUT for a monitor",
        "ffcine bake --camera dlog --exposure 0.5 --size 33 dlog_709.cube",
        "# The analytic Pocket 4 D-Log -> BT.2020 PQ transform, mastered to 1000 cd/m²",
        "ffcine bake --camera dlog --hdr-peak 1000 dlog_pq.cube",
    ),
    see!("color.md#lut-baker")
);

pub const IMPORT: &str = concat!(
    examples!(
        "ffcine import edit.fcpxml -o timeline.otio",
        "ffcine import --format resolve --size 3840x2160 resolve_export.otio -o timeline.otio",
        "ffcine import --rate 24000/1001 --strict cut.edl -o timeline.otio",
        "ffcine import ~/CapCut/draft/0601 -o timeline.otio   # a CapCut draft folder",
    ),
    see!("import.md")
);

pub const TRIAGE: &str = concat!(
    examples!(
        "ffcine triage ~/projects/trip",
        "ffcine triage -j 8 --language en --hotwords 'gelato, piazza' ~/projects/trip",
        "ffcine triage --no-transcribe -o /tmp/triage /media/CARD",
        "ffcine triage --immich http://photos.example/api --camera-tz +02:00 ~/projects/trip",
    ),
    see!("triage.md")
);

pub const BREAKDOWN: &str = concat!(
    reference!(
        "Writes, in DIR:",
        "breakdown.json      every shot: in/out, frames, keyframes, motion, colour, beat, speech, text",
        "shotlist.md         # | Time | Dur | Motion | Notes | Keyframe (♪: cut on the beat)",
        "contact-sheet.jpg   the keyframes in a grid, numbered, with each shot's length (contact-sheet-2.jpg... past 12 rows)",
        "keyframes/NNN.jpg   each shot's sharpest middle frame; NNN-first.jpg, NNN-last.jpg",
    ),
    examples!(
        "ffcine breakdown reel.mp4",
        "ffcine breakdown -o /tmp/ref --no-transcribe trailer.mkv",
        "# Fast cutting: shots down to 3 frames, a lower cut threshold",
        "ffcine breakdown --min-shot 0.1 --threshold 0.22 reel.mp4",
        "# ffcine reads local files only: download first",
        "yt-dlp -o ref.mp4 URL && ffcine breakdown ref.mp4",
    ),
    see!("breakdown.md")
);

pub const SEARCH: &str = concat!(
    examples!(
        "ffcine search sunset",
        "ffcine search --paths 'over there'",
        "ffcine search --index ~/projects/trip/triage/index.json gelato",
    ),
    see!("triage.md")
);

pub const SNAP: &str = concat!(
    examples!(
        "# Move a rough 2.7 s - 7.3 s cut onto the nearest word edges",
        "ffcine snap MVI_2884 2.7 7.3",
        "ffcine snap --keep --pad 0.2 DJI_0001_D 1:02.5 1:09",
    ),
    see!("triage.md")
);

pub const CHAPTERS: &str = concat!(
    examples!(
        "ffcine chapters timeline.otio",
        "ffcine chapters --intro Welcome -o chapters.txt timeline.otio",
    ),
    see!("delivery.md#youtube-chapters")
);

pub const TAG: &str = concat!(
    examples!(
        "ffcine tag --timeline timeline.otio --cover 0:42 master.mp4",
        "ffcine tag --cover cover.jpg master.mp4",
        "ffcine tag --clear --timeline timeline.otio master.mp4",
    ),
    see!("delivery.md")
);

pub const CREDITS: &str = concat!(
    examples!(
        "ffcine credits timeline.otio",
        "ffcine credits --public -o credits.txt timeline.otio",
    ),
    see!("delivery.md#music-credits")
);

pub const STILL: &str = concat!(
    examples!(
        "ffcine still --at 1:23 timeline.otio",
        "ffcine still --at 95.5 --size 1280x720 -o thumb.png timeline.otio",
        "ffcine still --at 0:42 --no-text -o frame.png timeline.otio",
        "ffcine still --at 2.6 -o f.png story.comp.json   # a comp: every layer and text",
    ),
    see!("delivery.md#stills")
);

pub const COMP: &str = concat!(
    examples!(
        "ffcine comp check story.comp.json",
        "ffcine render -o story.mp4 story.comp.json",
        "ffcine still --at 2.6 -o f.png story.comp.json",
    ),
    see!("compositing.md")
);

pub const COMP_CHECK: &str = concat!(
    reference!(
        "Comp file (ffcine.comp/1, JSON or YAML; unknown keys are errors, x_* keys are ignored):",
        "schema    \"ffcine.comp/1\"",
        "canvas    size [w, h] px, fps, duration s, background (colour), fade (1 = picture),",
        "          fade_colour",
        "vars      {name: track}, readable by every expression",
        "project   an ffcine.project/1 object or a timeline file whose project grades go",
        "          through; with one, every video, still and comp layer is graded",
        "comps     {name: {canvas, vars, layers}}, nested comps used by name",
        "template  instead of canvas, vars, comps and layers: {story: {...}} or {layout,",
        "          panels, size, fps, duration}, written out when read (see comp lower)",
        "layers    bottom first: id, source, in, out, source_in, size [w, h], fit (cover,",
        "          contain, fill, none), content {zoom, zoom_center, crop_x, crop_y, focus, punch,",
        "          push, push_center, pan} (the picture inside the box), crop {left, right, top,",
        "          bottom, feather} (fractions of the source), transform {position, anchor, scale,",
        "          rotation, opacity}, radius, mask {shape: rect|ellipse, rect, feather, invert},",
        "          blend, fade, fade_colour, grade, time, filter, vars",
        "time      video layers: {speed: track} (source s per layer s, integrated from",
        "          source_in; negative runs back), {source: track} (source s), {freeze: s},",
        "          {reverse: true} (alone or with speed); frames picked as setpts + fps do",
        "grade     video, still and comp layers, as a clip's ffcine.color.grade/1: look,",
        "          camera (auto probes the source; video only), lut, lut_strength, rgb_gain,",
        "          sharpen, exposure, warmth, tint, contrast, gamma, lift, gain, saturation",
        "          (tracks), filters [\"eq=contrast=1.1\" or {eq: {option: track}}] (eq, hue,",
        "          colorbalance, colorlevels (layers only, not timeline extra), curves,",
        "          unsharp, vignette; others are errors: use filter),",
        "          shaders [{shader: NAME or PATH, params: {name: track}}]",
        "filter    video layers: {graph, hw: cpu|cuda|vulkan, clock: layer|comp, preroll,",
        "          postroll, whole_clip, timing: free}, a raw libavfilter graph run in the prep",
        "          (lossless intermediate); t is the layer's (or comp's) time in every piece;",
        "          stateful filters get rolls or the whole clip from a known table; n is an",
        "          error unless whole_clip; several chains or labels name ends [in] and [out];",
        "          the graph keeps the layer's size and frame count; only allowlisted",
        "          filters run (docs/compositing.md): pixel filters, plus readers (lut3d, lut1d,",
        "          subtitles, ass, drawtext, ...) whose files resolve against the comp's dir",
        "          and are keyed by contents; writers, sources, plugin loaders and URLs are",
        "          refused",
        "source    {video: PATH}, {still: PATH} (PNG, JPEG, WebP, HEIC; colour tags converted,",
        "          untagged taken as the output's space), {solid: COLOUR}, {text: {text, ...}}",
        "          (ffcine.text parameters), {comp: NAME or {canvas, vars, layers}}",
        "Every number is a track: a constant, keys [{t, v, interp}] (t: s from the layer's in),",
        "an expression string (t, lt, n, W, H, w, h, in, out, dur, T, vars; ffmpeg's functions),",
        "or {keys, expr}. Colours: [r, g, b(, a)] sRGB 0..1, black, white, #rrggbb[aa].",
        "Limits: canvas ≤ 16384 px a side, ≤ 8192x8192 px, aspect ≤ 4:1; fps 1..240;",
        "duration, in, out, source_in 0..3600 s; ≤ 64 layers (≤ 16 video), nested comps'",
        "included; comps nest ≤ 4 deep, at the parent's fps; unique string ids;",
        "layer size ≤ 65536 px; a layer's texture at its largest scale ≤ 16384 px a side;",
        "rotation within ±1e6 degrees; time speed within ±100, time source 0..3600 s.",
        "The canvas fade covers the composite, every blend mode.",
    ),
    examples!(
        "ffcine comp check story.comp.json",
        "ffcine comp check --profile master story.comp.json",
    ),
    see!("compositing.md")
);

pub const COMP_LOWER: &str = concat!(
    reference!(
        "Templates (\"template\": {...} in a comp file):",
        "story     {story: {size, fps, duration, tiles, gutter, radius, background (colour or",
        "          {from, to, duration}), enter {fade, stagger}, swap {at, stagger, slide,",
        "          direction}, exit {at, stagger, slide, fade, direction}, ken_burns [from, to],",
        "          grade, panels: [[item, item?], ...]}}: tiles stacked top to bottom, each a",
        "          nested comp whose second item slides in over the first at swap.at",
        "          (swap.stagger later per tile); item = {video: PATH, source_in, punch {at,",
        "          zoom, duration}, speed [[t, x], ...]} or {still: PATH} (Ken Burns), id, grade",
        "layout    {layout: ffcine.comp.layout/1 {layout split|row|column|1+2, gap, margin,",
        "          radius, weights, stagger, slide, enter, first_static}, panels [item, ...],",
        "          size, fps, duration}: the timeline's split-screen geometry over the backdrop",
        "          colour (its corner vignette is timeline-only)",
        "A file without a template prints as it is read (checked the same way).",
    ),
    examples!(
        "ffcine comp lower story.comp.json",
        "ffcine comp lower story.comp.yaml -o story.lowered.comp.json",
    ),
    see!("compositing.md")
);

pub const COMP_NEW: &str = concat!(
    reference!(
        "Story starter:",
        "Media are dealt round the tiles (tile 1 gets the 1st and (tiles+1)th file, ...), so",
        "at most two a tile; a tile's second item swaps in. --tiles is at most the number of",
        "files, and --duration has a floor (2.4 s for 3 tiles with a swap; the error says).",
        "Stills by extension (png, jpg, jpeg, heic, heif, avif, webp, tif, tiff, exr),",
        "anything else is a video. Paths are written relative to the output's directory",
        "(../ when outside it). Edit the result, then comp check, comp lower or render it.",
    ),
    examples!(
        "ffcine comp new --template story -o story.comp.json --media a.mp4 --media b.heic",
        "ffcine comp new --template story --tiles 2 --duration 8 -o s.comp.json \\",
        "    --media a.mp4 --media b.mp4 --media c.jpg --media d.jpg",
    ),
    see!("compositing.md")
);

pub const LOOKS: &str = concat!(
    reference!(
        "Variants (-v [NAME:]SPEC):",
        "SPEC is a project look name, a .cube file or LUT alias, `cut` (the clip's own grade),",
        "or comma-separated look=NAME, lut=ALIAS|FILE|none and grade fields:",
        "exposure, warmth, tint, contrast, gamma, lift, gain, saturation, lut_strength, sharpen.",
        "Each variant layers like a clip grade:",
        "project grade <- look <- source-kind rule <- the clip's override <- the variant's fields.",
        "rgb_gain and extra filters apply; denoise and extra filters with no GPU equivalent are",
        "skipped with a warning.",
    ),
    examples!(
        "# The cut against every project look, at two times",
        "ffcine looks --at 0:12 --at 1:40 timeline.otio",
        "# Named variants on 6 shots spread over the timeline",
        "ffcine looks --sample 6 -v warm:look=day,warmth=0.04 -v neutral:lut=none -v film.cube timeline.otio",
        "# A clip outside the timeline",
        "ffcine looks --clip MVI_2884.MP4@3.5 -v warm.cube -v cut -o looks/r10",
    ),
    see!("delivery.md#look-comparisons", "timeline.md#gradeparams")
);

pub const SUBS: &str = concat!(
    examples!(
        "ffcine subs --into timeline.otio captions.ass",
        "ffcine subs --offset=-1.5 --into timeline.otio -o captioned.otio captions.ass",
    ),
    see!("text.md#subtitles-from-ass")
);

pub const TITLE: &str = concat!(
    reference!(
        "--set keys (ffcine.title parameters, values as JSON):",
        "title_style, subtitle_style, lines_style   style overrides per piece (font, face, size,",
        "                                           colour, highlight, shadow, ...)",
        "rule, background, stagger, duration, fade_out, position, alignment, gap",
    ),
    examples!(
        "ffcine title --into edit.otio \"The Long Road\" --subtitle \"A short film\"",
        "ffcine title --into edit.otio --end \"Thanks for watching\" --line \"Music: Example Artist (CC BY 4.0)\" --line \"Made with ffcine\"",
        "ffcine title --into edit.otio \"Day one\" --at 12.5 --duration 4 --no-rule --set 'title_style={\"font\":\"DejaVu Serif\",\"face\":\"Light\"}' -o titled.otio",
        "ffcine still --at 2 -o title.png titled.otio",
    ),
    see!("text.md#titles-and-end-screens", "timeline.md#ffcinetitle")
);

pub const RENDER: &str = concat!(
    reference!(
        "Timeline keys (OTIO JSON, under metadata.ffcine; docs/timeline.md):",
        "project      ffcine.project/1 on the timeline: rate, grade, default_look, looks, luts,",
        "             shaders, rules, styles, output, mix",
        "output       aspect (landscape, reel, feed, square, WxH, NAME=WxH);",
        "             preview / master: size [w, h], max_punch",
        "mix          lufs, true_peak, headroom, normalize, fade_out, dialogue_lufs, nat_lufs,",
        "             gain_range, xfade, duck_db, nat_duck_db, duck_attack, duck_release,",
        "             sidechain, duck_threshold, clean, clean_nat, highpass, buses (docs/audio.md)",
        "grade        camera, lut, lut_strength, rgb_gain, warmth, tint, exposure, contrast,",
        "             gamma, lift, gain, saturation, denoise, sharpen, extra, extra_light",
        "clips        id, label, source, ai, hold_of, music, attribution, private, public_alt",
        "markers      chapter",
        "Grades layer: project.grade <- look <- light rule <- clip override.",
    ),
    reference!(
        "Effects (schema + parameters on a clip or stack):",
        "ffcine.color.grade/1   look + any grade field (the clip override)",
        "ffcine.color.cdl/1     ASC CDL slope, offset, power, saturation",
        "ffcine.fx.denoise/1    preset (night, snow), luma, chroma, protect, temporal {radius,",
        "                       motion (nvof, blocks), strength, chroma} (docs/denoise.md)",
        "ffcine.fx.grain/1      strength, size, chroma, profiles",
        "ffcine.fx.stabilize/1  smoothing, zoom (auto or a factor), max_zoom (docs/stabilize.md)",
        "ffcine.fx.shader/1     shader, params: an mpv/libplacebo hook (docs/hooks.md)",
        "ffcine.fx.fade/1, ffcine.fx.flash/1, ffcine.fx.vignette/1, ffcine.fx.darken/1",
        "ffcine.geo.reframe/1   zoom, punch, push, pan, fit, crop_x, crop_y, focus, profiles",
        "                       (docs/reframe.md)",
        "ffcine.geo.crop/1, ffcine.geo.transform/1   picture-in-picture: crop, anchor,",
        "                       translate, scale, rotate_deg",
        "ffcine.comp.blend/1    opacity, radius, mode: normal, add, subtract, difference, multiply,",
        "                       screen, overlay, hardlight, softlight, ... (Resolve's 27 modes)",
        "ffcine.comp.layout/1   split screens: layout (split, row, column, 1+2), gap, margin, ...",
        "ffcine.time.ramp/1     speed ramp points [[u, speed]]; ffcine.time.blend/1 frames",
        "ffcine.audio.level/1, .clean/1, .pan/1, .loop/1   per-clip audio (docs/audio.md)",
        "Transitions: dissolve, cross_fade, wipe, dip, flash, whip.",
        "Numbers may be keyframes [{t, v, interp}] (t: source time) or an expression over",
        "st, e.g. \"1 + 0.05*sin(st*4)\" (ffmpeg's functions; docs/compositing.md).",
    ),
    reference!(
        "Generators (text, colour and comp clips; docs/text.md, docs/timeline.md):",
        "ffcine.text    text, style, font, face, size, colour, position, alignment, profiles,",
        "               animation {in, out}: fade, slide, pop, typewriter, wipe, none;",
        "               keyframes {position, scale, opacity, colour}: [{t, v, interp}], or",
        "               expressions over lt and dur",
        "ffcine.title   title sequence or end screen (`ffcine title`)",
        "ffcine.solid   a colour fill",
        "ffcine.comp    a comp on the picture track: schema ffcine.comp/1 and src (a comp",
        "               file) or the comp inline; source_range is comp time",
        "HDR output is a `grade --hdr` option; render writes BT.709 SDR (docs/color.md).",
    ),
    examples!(
        "ffcine render -o preview.mp4 timeline.otio",
        "ffcine render --profile master -o master.mp4 timeline.otio",
        "ffcine render --render-profile reel -o reel.mp4 timeline.otio",
        "ffcine render --dry-run -o master.mp4 timeline.otio   # what would render",
        "ffcine render --profile review --cover 0:42 -o review.mp4 timeline.otio",
        "ffcine render --lufs=-16 --sidechain --json -o master.mp4 timeline.otio",
    ),
    see!("timeline.md", "assembly.md", "text.md", "color.md")
);

pub const DIFF: &str = concat!(
    examples!(
        "ffcine diff timeline.v1.otio timeline.v2.otio",
        "ffcine diff --profile master --render-profile reel --all v1.otio v2.otio",
    ),
    see!("assembly.md")
);

pub const PUBLISH: &str = examples!(
    "ffcine publish --immich 'Review' preview.mp4",
    "ffcine publish --immich 'Trip cuts' --config immich.json reel.mp4 master.mp4",
);

pub const INGEST: &str = concat!(
    examples!(
        "ffcine ingest --to ~/projects/trip /media/CARD",
        "ffcine ingest --to ~/projects/trip --backup /media/backup/trip /media/CARD",
        "# The R10's clock ran 2 min fast: fix it while copying",
        "ffcine ingest --dry-run --to ~/projects/trip --camera r10 --clock r10=-2m /media/CARD/DCIM",
    ),
    see!("ingest.md")
);

pub const CATALOG: &str = concat!(
    examples!(
        "ffcine catalog ~/projects/trip",
        "ffcine catalog --full --histogram always ~/projects/trip",
        "ffcine catalog --zone +02:00 --clock pocket=+1h ~/projects/trip",
    ),
    see!("ingest.md")
);

pub const DOCTOR: &str = examples!("ffcine doctor");

pub const SYNC: &str = concat!(
    examples!(
        "ffcine sync --audio recorder/ --video footage/ -o synced.otio",
        "ffcine sync --audio rec.wav --video MVI_2884.MP4 --timeline timeline.otio --wind -o timeline.otio",
        "ffcine sync --audio recorder/ --video footage/ --lead 0.5 --tail 1 --min-confidence 0.3 -o synced.otio",
    ),
    see!("audio.md")
);

pub const AUDIO: &str = concat!(
    examples!(
        "ffcine audio -o mix.wav timeline.otio",
        "ffcine audio --stems stems/ --lufs=-16 --true-peak=-1.5 -o mix.wav timeline.otio",
    ),
    see!("audio.md")
);

pub const BEATS: &str = examples!("ffcine beats song.mp3", "ffcine beats --json song.flac");

#[cfg(test)]
mod tests {
    /// The `--help` of `ffcine ARGS` as markdown (unwrapped), full or brief (`-h`).
    fn help(args: &[&str], full: bool) -> String {
        let mut words = args.to_vec();
        words.extend(["--help", "--help"]);
        match crate::cmd().run_inner(&words[..]) {
            Err(bpaf::ParseFailure::Stdout(doc, _)) => doc.render_markdown(full),
            other => panic!("ffcine {args:?} --help: {other:?}"),
        }
    }

    /// Commands and their one-line descriptions from the top-level help.
    fn commands() -> Vec<(String, String)> {
        let md = help(&[], true);
        let mut lines = md.lines().skip_while(|l| *l != "**Available commands:**");
        let mut out = Vec::new();
        while let Some(l) = lines.next() {
            if let Some(name) = l.strip_prefix("- **`").and_then(|l| l.split('`').next()) {
                out.push((
                    name.to_owned(),
                    lines.next().unwrap_or("").trim().to_owned(),
                ));
            }
        }
        out
    }

    #[test]
    fn long_help_flag() {
        let words = |v: &[&str]| {
            v.iter()
                .map(Into::into)
                .collect::<Vec<std::ffi::OsString>>()
        };
        assert_eq!(
            crate::long_help(words(&["grade", "--help"])),
            words(&["grade", "--help", "--help"])
        );
        assert_eq!(crate::long_help(words(&["-h"])), words(&["-h"]));
        assert_eq!(
            crate::long_help(words(&["publish", "--", "--help"])),
            words(&["publish", "--", "--help"])
        );
    }

    #[test]
    fn command_list_fits_one_line_each() {
        let cmds = commands();
        assert!(cmds.len() > 10, "{cmds:?}");
        // bpaf lists only the first line of a description: it must be a sentence that fits.
        let long: Vec<_> = cmds
            .iter()
            .filter(|(_, d)| !d.ends_with('.') || d.len() > 80)
            .collect();
        assert!(
            long.is_empty(),
            "first doc lines should be one short sentence: {long:#?}"
        );
    }

    /// Every command: every flag and positional has a help line, there are examples, and the full
    /// help says more than the brief one.
    #[test]
    fn every_command_is_documented() {
        let mut problems = Vec::new();
        for (name, _) in commands() {
            let md = help(&[&name], true);
            let lines: Vec<&str> = md.lines().collect();
            for (i, l) in lines.iter().enumerate() {
                if l.starts_with("- **") || l.starts_with("- _") {
                    let next = lines.get(i + 1).copied().unwrap_or_default();
                    let text = next.trim();
                    if !next.starts_with("  ") || text.is_empty() || text.starts_with("[default") {
                        problems.push(format!("{name}: no help for {l}"));
                    }
                }
            }
            let usage = lines
                .iter()
                .find(|l| l.starts_with("**Usage**"))
                .unwrap_or(&"");
            for word in usage.split_whitespace() {
                let word = word.trim_start_matches("\\[");
                if let Some(meta) = word.strip_prefix("_`").and_then(|w| w.split('`').next()) {
                    // A command group (`comp check`) lists its commands instead.
                    let group = meta == "COMMAND" && md.contains("Available commands");
                    if !group && !md.contains(&format!("- _`{meta}`_")) {
                        problems.push(format!("{name}: no help for positional {meta}"));
                    }
                }
            }
            let examples = md.split_once("Examples:").map_or("", |(_, e)| e);
            if !examples.contains(&format!("ffcine {name}")) {
                problems.push(format!("{name}: no examples"));
            }
            if help(&[&name], false).len() >= md.len() {
                problems.push(format!("{name}: --help says no more than -h"));
            }
        }
        assert!(problems.is_empty(), "\n{}", problems.join("\n"));
    }

    /// GitHub's anchor for a markdown heading.
    fn slug(heading: &str) -> String {
        heading
            .trim_start_matches('#')
            .trim()
            .to_lowercase()
            .chars()
            .filter_map(|c| match c {
                ' ' => Some('-'),
                c if c.is_alphanumeric() || c == '-' || c == '_' => Some(c),
                _ => None,
            })
            .collect()
    }

    /// Every `docs/PAGE.md[#anchor]` the help points to exists, and no help says something is
    /// not done yet.
    #[test]
    fn help_links_resolve() {
        let docs = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs");
        // A packaged source (the nix build, crates.io) has no docs/; the checkout's run checks links.
        let has_docs = docs.is_dir();
        let mut pages = vec![help(&[], true)];
        pages.extend(commands().iter().map(|(name, _)| help(&[name], true)));
        let mut problems = Vec::new();
        for md in &pages {
            for (i, _) in md.match_indices("docs/").filter(|_| has_docs) {
                let link: String = md[i + 5..]
                    .chars()
                    .take_while(|c| c.is_alphanumeric() || "-_.#".contains(*c))
                    .collect();
                let link = link.trim_end_matches('.');
                let (page, anchor) = link.split_once('#').unwrap_or((link, ""));
                if page.is_empty() {
                    continue;
                }
                let Ok(text) = std::fs::read_to_string(docs.join(page)) else {
                    problems.push(format!("no docs/{page}"));
                    continue;
                };
                if !anchor.is_empty()
                    && !text
                        .lines()
                        .any(|l| l.starts_with('#') && slug(l) == anchor)
                {
                    problems.push(format!("no #{anchor} in docs/{page}"));
                }
            }
            for stale in ["not applied yet", "not implemented", "TODO"] {
                if md.contains(stale) {
                    problems.push(format!("help says {stale:?}"));
                }
            }
        }
        problems.sort();
        problems.dedup();
        assert!(problems.is_empty(), "\n{}", problems.join("\n"));
    }
}