diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 6480e09bb2..a875efbf0a 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -82,6 +82,50 @@ grep `specs/` for every name it would import. If any of them collides, put the n spec that owns those names, under a prefix such as `beat_`. The beat clock (t27#10596, PR #10598) lives in `lesson_reel.t27` for this reason. +**Cutting video at a ms time: land on a frame, then measure the runs (t27#10603, PR #10605).** + +ffmpeg `xfade` starts on the first frame whose time is at or after `offset`. This is how to get the cut you planned: + +- Floor the cut to a base frame: `f = (c - half) * fps / 1000`. +- Print the offset as `trunc(f * 1000 / fps)` ms. Rounding up misses frame f on 2 frames in 3. +- Put every input through `fps=30,settb=AVTB` so the offset is not rounded again. +- Chained `xfade` offsets are absolute reel times when each clip starts at its own frame 0. + +To check a graph without looking at pictures: + +1. Feed it solid `color=` inputs at `s=2x2`. +2. Write `-f rawvideo -pix_fmt rgb24`. +3. Count the runs with `od -An -tu1 -w12 -v | cut -c1-12 | uniq -c`. + +The runs are the scene lengths and must equal the spec's frame counts. Expect one extra frame when the offset falls on a whole ms. + +Lesson main functions read `std.c.getenv`, so build them on the lab with `zig build-exe -lc`. + +**Adding a mode to a graph printer without moving the old ones (t27#10609, PR #10610).** + +- Pass the old callers a flag (`graph(..., false)`) instead of copying the printer. +- Give the input numbers a function with a test that every input is used exactly once, in each layout. +- Prove the old output did not move: on the lab, build the old spec (from `git show :path`) and the new one, run both on a grid of lessons, languages and voices, and `cmp -s` every graph. PR #10610 compared 96 graphs and 0 differed. +- For a large frame, render lossless (`-c:v libx264 -qp 0`) and crop a 2x2 pixel outside every text box (`crop=2:2:x:y,format=rgb24`). The solid runs must still equal the spec's frame counts. + +Traps from this tick: + +- An untyped array literal in a test (`var a = [_]usize{...}`) becomes a Zig tuple, which cannot be indexed at runtime ("tuple field index must be comptime-known"). Declare `var a : [8]usize = undefined;` and fill it in a loop. +- `fs-lab.sh "echo $b64a ... ; echo $b64b ..."` with two large files exits 0 and writes nothing. Upload one file per call and compare md5 on both sides. +- zsh does not word-split `set -- $p`. Write one call per file. + +**Keeping overlays off a crossfade (t27#10614, PR #10627).** + +- Work in whole frames, not seconds. Give each scene a function for the frames it owns outside its crossfades, clip each overlay to it, and print exactly those frames as `between(n,lo,hi)` terms joined with `+`. A test checks that the printed frames equal the frames `between(t,a,b)` showed before. +- Drop a piece that has no room; never squeeze it. Name the cost in a test: count what is dropped, assert that each drop is on a crossfade, and assert that every phrase keeps at least one caption. +- To check the pixels, render lossless from solid-colour clips. Crop a full-width band 40 rows tall over the text, split the raw file per frame, and count the distinct colours per frame with `od -An -tu1 -w3 -v | sort -u | wc -l`. Crossfade frames must show 1. The frames with text are the control: they must be many. + +Traps from this tick: + +- A minimum-length rule silently drops spoken captions. A short chunk that straddles a crossfade leaves two pieces under the minimum, and the voice speaks words that are never shown. A grid test that says "no overlay on a crossfade" stays green while this happens. Only a count of what was dropped finds it. +- Lab scripts run under `sh`, which is dash: `$((10#$x))` fails with "expecting EOF". Strip leading zeros with `sed 's/^0*//'` and default with `${i:-0}`. +- The Mac hook reads the whole Bash command. A heredoc PR body that names the report command gets refused. Write the body with the Write tool and pass `--body-file`. + ## Show a spec (the core: exporter for what it reads, by hand for the rest) 1. Load the spec: `mcp__t27__t27_spec` with `path` and `source: true`, or the file from a diff --git a/.trinity/seals/skills_skill_t27_reel_beat.json b/.trinity/seals/skills_skill_t27_reel_beat.json new file mode 100644 index 0000000000..cc0c73bb2a --- /dev/null +++ b/.trinity/seals/skills_skill_t27_reel_beat.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:5642bc71a3f58fb401c34d2e9260f2190b2335994b58272bfd47bdb2e50cca07", + "gen_hash_rust": "sha256:706879bc5d6542378435b0982dbb44e52cc1b9b85b34d7f06149ec7668b5fdd7", + "gen_hash_verilog": "sha256:596ced743ed9651f967ac93bae97d97f44b882ea6ed659d4ebc6ec67a16829b8", + "gen_hash_zig": "sha256:24befaf4ec44f81b268d7a1f58e3e7312c16da94c50b0442ac76e23e5ea1440a", + "module": "skill_t27_reel_beat", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T19:16:57Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:b609e2d632860b22d61a44f12e3d8e3bf11663d080dada9cec88c19ac9e5becd", + "spec_path": "specs/skills/t27-reel-beat.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 1, + "total": 1, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 5a1643fc9e..6555450c57 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,27 +31,27 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:6a381e722c19647a8f05c723633503d1d931b143538c6183f8832fec577ebeb7", - "gen_hash_rust": "sha256:bf7b0432da8ec2e1bcf0f139aa271e9df74986d67c81dc1bb0bcacd19357d1db", - "gen_hash_verilog": "sha256:836331bc2320ec6d80ce1580e30f1d3a9cc9fbd89325995f141c090282a68ec7", - "gen_hash_zig": "sha256:50a202697595b03abac33b3707f25a239343ae7aa2256b0c43fec31b950cf61b", + "gen_hash_c": "sha256:30f4f017bc4dd4c32c1ec195d83f827f10e9d260f30056ebd6f444cf034ca3ea", + "gen_hash_rust": "sha256:cd6858c82060106802b3cced271e84811d8ee02b8c4f2ed18ba75cf8068861eb", + "gen_hash_verilog": "sha256:d9d8bbee11770df310cf863654c914b318ea03940780b5a9e116cec3f4e33991", + "gen_hash_zig": "sha256:f5762a5368a29d6b2978d7ea365deed7c9c7a0d4994814a3d6586c039fdcbf25", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T18:33:33Z", + "sealed_at": "2026-10-18T19:36:34Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:87165b892aaaba0647fe1657669524eca65c31547f11f4e4994abb830c673b0e", + "spec_hash": "sha256:b1ca187bbf04f6595be7dbf25b6a656286b07a86c50def9a0605c9ec56f017ec", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 13, - "total": 13, + "passed": 25, + "total": 25, "vacuous": 0 }, "toolchain": { - "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", "zig": "0.16.0" } } \ No newline at end of file diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index c5421014bf..b826c335a9 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -1407,6 +1407,7 @@ {"path": "specs/skills/t27-measure-corpus.t27", "t27b": "pass"}, {"path": "specs/skills/t27-phi-loop.t27", "t27b": "pass"}, {"path": "specs/skills/t27-providers-steward.t27", "t27b": "pass"}, + {"path": "specs/skills/t27-reel-beat.t27", "t27b": "pass"}, {"path": "specs/skills/t27-self-host.t27", "t27b": "pass"}, {"path": "specs/skills/t27-t27f.t27", "t27b": "pass"}, {"path": "specs/skills/t27-tri-pipeline.t27", "t27b": "pass"}, diff --git a/specs/skills/t27-reel-beat.t27 b/specs/skills/t27-reel-beat.t27 new file mode 100644 index 0000000000..8b7d3bd7ab --- /dev/null +++ b/specs/skills/t27-reel-beat.t27 @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/skills/t27-reel-beat.t27 -- skill t27/reel-beat +; Source of truth for the skill card on t27.ai (#/skills?skill=t27/reel-beat). +; How TRI CLAW cuts a lesson reel on the beat: LR_MODE=4 crossfades the open, main and close +; scene clips at the beat clock's cuts, on whole frames, and LR_MODE=5 lays mode 3's captions, +; cards and address over that video. Mode 5's inputs are 0..2 the scene clips, 3..5 the +; phrases' wavs and 6 the music. The law lives once, in specs/t27f/lesson_reel.t27 (beat_* +; functions and their tests); this card points at it and copies none. The scene clips are +; inputs: lesson_reel does not read the content factory's scene plan yet. There is no +; SKILL.md behind it yet, so the card is spec-only (see "Witness labels" in +; specs/skills/README.md). Issues gHashTag/t27#10603 and +; gHashTag/t27#10609, epic gHashTag/t27#10560. +; The JSON the site serves is generated from this file by the real compiler; +; nothing below is parsed with a regex. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module skill_t27_reel_beat; + +pub const KIND : str = "skill"; +pub const ID : str = "t27/reel-beat"; +pub const NAME : str = "Reel Beat"; +pub const REPO : str = "t27"; +; No SKILL.md exists for this card. +pub const SOURCE : str = ""; +pub const SUMMARY_EN : str = "Cut a 15 second lesson reel on the beat clock: crossfade the open, main and close scenes at whole-frame cuts that never fall inside a spoken phrase, lay the captions and address on top, and refuse a voice that does not fit"; +pub const COMMAND : str = "/reel-beat"; +; Spec this skill stands on: the beat clock and the graph printer. +pub const SPECS : [1]str = ["specs/t27f/lesson_reel.t27"]; +pub const TAGS : [4]str = ["social-visual", "reel", "beat", "t27f"]; +pub const ENABLED : bool = true; +; Default budget; no SKILL.md declares a timeout. +pub const TIMEOUT_MIN : u16 = 30; + +test basic_constants { + assert KIND == "skill"; + assert ID == "t27/reel-beat"; + assert NAME == "Reel Beat"; + assert REPO == "t27"; + assert SOURCE == ""; + assert COMMAND == "/reel-beat"; + assert ENABLED == true; + assert TIMEOUT_MIN == 30; + assert TAGS.len == 4; + assert TAGS[0] == "social-visual"; + assert TAGS[2] == "beat"; + assert SPECS.len == 1; + assert SPECS[0] == "specs/t27f/lesson_reel.t27"; +} diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index b6a0455048..fcc8cd1800 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -15,6 +15,11 @@ // 3 the two ffmpeg -filter_complex_script graphs, tall then wide, given LR_MS0..LR_MS2, the // phrases' lengths in ms as the voice service returned them. Inputs: 0 the frames, // 1..3 the phrases' wavs, 4 the music. +// 4 the beat graph (t27#10603), given LR_MS0..LR_MS2: inputs 0..2 the open, main and close +// scene clips, crossfaded on whole frames at the beat clock's cuts and trimmed to the reel +// 5 mode 3's two graphs laid over mode 4's beat video (t27#10609): inputs 0..2 the scene +// clips, 3..5 the phrases' wavs, 6 the music. The captions, tags and address are shown on +// whole frames inside the scenes and never on a crossfade (t27#10614, beat_piece_*) // Every text that comes from the lesson goes through a textfile, so no quote, colon or percent in // a lesson can break the graph (and drawtext runs with expansion=none). // @@ -37,8 +42,13 @@ // drops both (t27#10556). // // NOT CLAIMED: the backdrop is token_floor's physics world, not a picture of the lesson; its frame -// labels are the physics reel's. The beat clock is not yet wired into the graph: the frames that -// follow it are R4b. +// labels are the physics reel's. Mode 5 lays the same captions and address over every scene, in +// the same places, and only their windows change with the beat. The cards are still on their +// own frames over a crossfade: token_floor_mux prints their enable= and this file does not reach +// it. Both cuts are centred on a sentence change, so a crossfade can begin under the last words +// of a phrase: mode 5 then drops a caption with too little room left (the cost tests), and on a +// voice the reel had to speed up it holds the address for less than REEL_CTA_MIN_MS. The scenes +// themselves are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -514,9 +524,17 @@ pub fn page_chars(t: usize) -> usize { // ---- the graph ---- -fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize) -> void { - const a = SHOT_START[s]; +// With beats (f0, f1 the crossfades' first frames) a tag is shown on its kept pieces and slides in +// from the first of them; one with no piece is not printed. +fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize, beats: bool, f0: i32, f1: i32) -> void { + var a : i32 = SHOT_START[s]; const b = SHOT_START[s + 1] - 1; + if (beats) { + if (beat_pieces(a, b, f0, f1) == 0) { + return; + } + a = beat_first_lo(a, b, f0, f1); + } if (f == 99) { std.debug.print(",drawtext=fontfile={s}:expansion=none:text='{d:0>2}':fontsize={d}:fontcolor=", .{ FONT, s + 1, TAG_FONT[o] }); } else if (f == 98) { @@ -525,7 +543,12 @@ fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize) -> std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), f, TAG_FONT[o] }); } print_col(row); - std.debug.print(":x='{d}+{d}*pow(1-clip((n-{d})/{d},0,1),3)':y={d}:alpha='clip((n-{d})/{d},0,1)':enable='between(n,{d},{d})'", .{ TAG_X[o] + dx, TAG_SLIDE_PX, a, TAG_SLIDE_F, TAG_Y[o], a, TAG_SLIDE_F, a, b }); + std.debug.print(":x='{d}+{d}*pow(1-clip((n-{d})/{d},0,1),3)':y={d}:alpha='clip((n-{d})/{d},0,1)'", .{ TAG_X[o] + dx, TAG_SLIDE_PX, a, TAG_SLIDE_F, TAG_Y[o], a, TAG_SLIDE_F }); + if (beats) { + print_pieces(SHOT_START[s], b, f0, f1); + } else { + std.debug.print(":enable='between(n,{d},{d})'", .{ a, b }); + } } fn print_card_text(o: usize, c: usize, lang: usize, f: usize, font: i32, row: usize, dy: f64) -> void { @@ -555,23 +578,57 @@ fn print_card(o: usize, c: usize, t: usize, lang: usize) -> void { print_fade(c); } -fn print_cta(o: usize, t: usize, lang: usize, a: i32) -> void { +// The address and its page from ms a to the end. With beats they fade in from the first frame of +// their first kept piece and are on only on the pieces. +fn print_cta(o: usize, t: usize, lang: usize, a: i32, beats: bool, f0: i32, f1: i32) -> void { + const lo = beat_frame_from(a); + const hi = beat_frames() - 1; + var at : i32 = a; + if (beats) { + if (beat_pieces(lo, hi, f0, f1) == 0) { + return; + } + at = beat_frame_ms(beat_first_lo(lo, hi, f0, f1)); + } std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=", .{ FONT, CTA_TEXT, CTA_FONT }); print_col(GOLD_ROW); - std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}+({d}-text_w)/2:y={d}-text_h/2:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)':enable='gte(t,{d}.{d:0>3})'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), band_w(o), cta_y(o), secs(a), millis(a), millis(TF_CTA_FADE_MS), secs(a), millis(a) }); + std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}+({d}-text_w)/2:y={d}-text_h/2:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), band_w(o), cta_y(o), secs(at), millis(at), millis(TF_CTA_FADE_MS) }); + print_cta_enable(a, beats, lo, hi, f0, f1); std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), F_PAGE, PAGE_FONT }); print_col(GREY_ROW); - std.debug.print(":x={d}+({d}-text_w)/2:y={d}:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)':enable='gte(t,{d}.{d:0>3})'", .{ band_x(o), band_w(o), page_top(o), secs(a), millis(a), millis(TF_CTA_FADE_MS), secs(a), millis(a) }); + std.debug.print(":x={d}+({d}-text_w)/2:y={d}:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)'", .{ band_x(o), band_w(o), page_top(o), secs(at), millis(at), millis(TF_CTA_FADE_MS) }); + print_cta_enable(a, beats, lo, hi, f0, f1); +} + +fn print_cta_enable(a: i32, beats: bool, lo: i32, hi: i32, f0: i32, f1: i32) -> void { + if (beats) { + print_pieces(lo, hi, f0, f1); + } else { + std.debug.print(":enable='gte(t,{d}.{d:0>3})'", .{ secs(a), millis(a) }); + } } -pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { - std.debug.print("[0:v]format=yuv420p", .{}); +// With beats the picture is the beat chain over inputs 0..2 instead of input 0, and the voice and +// music inputs follow the three clips (beat_voice_input, beat_music_input). +pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32, beats: bool) -> void { + if (beats) { + beat_chain(t, lang, m0, m1, m2); + std.debug.print(",format=yuv420p", .{}); + } else { + std.debug.print("[0:v]format=yuv420p", .{}); + } const adv = @divTrunc(TAG_FONT[o] * MONO_ADVANCE_PERMILLE, 1000); + var f0 : i32 = 0; + var f1 : i32 = 0; + if (beats) { + f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + } var s : usize = 0; while (s < SHOTS) { - print_tag(o, s, 0, GOLD_ROW, 99, lang); - print_tag(o, s, 3 * adv, GREY_ROW, 98, lang); - print_tag(o, s, @as(i32, @intCast(TAG_TEXT_AT)) * adv, GREY_ROW, tag_file(s), lang); + print_tag(o, s, 0, GOLD_ROW, 99, lang, beats, f0, f1); + print_tag(o, s, 3 * adv, GREY_ROW, 98, lang, beats, f0, f1); + print_tag(o, s, @as(i32, @intCast(TAG_TEXT_AT)) * adv, GREY_ROW, tag_file(s), lang, beats, f0, f1); s = s + 1; } var c : usize = 0; @@ -587,20 +644,29 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void while (k < chunks(i)) { const a = chunk_from(t, lang, m0, m1, m2, p, k); const b = chunk_until(t, lang, m0, m1, m2, p, k); - std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), chunk_file(t, lang, p, k), band_font(o, @as(i32, @intCast(chunk_chars(i, k)))) }); - print_col(GOLD_ROW); - std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}:y={d}-text_h/2:enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), caption_y(o), secs(a), millis(a), secs(b), millis(b) }); + const fa = beat_frame_from(a); + const fb = beat_frame_until(b); + if (beats == false || beat_pieces(fa, fb, f0, f1) > 0) { + std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), chunk_file(t, lang, p, k), band_font(o, @as(i32, @intCast(chunk_chars(i, k)))) }); + print_col(GOLD_ROW); + std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}:y={d}-text_h/2", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), caption_y(o) }); + if (beats) { + print_pieces(fa, fb, f0, f1); + } else { + std.debug.print(":enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ secs(a), millis(a), secs(b), millis(b) }); + } + } k = k + 1; } p = p + 1; } - print_cta(o, t, lang, cta_from(t, lang, m0, m1, m2)); + print_cta(o, t, lang, cta_from(t, lang, m0, m1, m2), beats, f0, f1); std.debug.print("[v];", .{}); const tp = tempo(m0, m1, m2); p = 0; while (p < n) { const at = phrase_start(m0, m1, m2, p); - std.debug.print("[{d}:a]aresample=48000,atempo={d}.{d:0>3},pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ p + 1, secs(tp), millis(tp), at, at, p }); + std.debug.print("[{d}:a]aresample=48000,atempo={d}.{d:0>3},pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ beat_voice_input(beats, p), secs(tp), millis(tp), at, at, p }); p = p + 1; } p = 0; @@ -609,7 +675,7 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void p = p + 1; } std.debug.print("amix=inputs={d}:normalize=0:duration=longest,loudnorm=I={d}:TP=-1.5:LRA={d},aresample=48000,apad=whole_dur={d}[vo];", .{ n, VOICE_LUFS, VOICE_LRA, secs(REEL_MS) }); - std.debug.print("[4:a]atrim=0:{d},volume=0.{d:0>3},afade=t=in:d=0.{d:0>3},afade=t=out:st={d}.{d:0>3}:d={d}.{d:0>3}[mu];", .{ secs(REEL_MS), millis(MUSIC_VOLUME_PERMILLE), millis(MUSIC_FADE_IN_MS), secs(MUSIC_FADE_OUT_START_MS), millis(MUSIC_FADE_OUT_START_MS), secs(MUSIC_FADE_OUT_MS), millis(MUSIC_FADE_OUT_MS) }); + std.debug.print("[{d}:a]atrim=0:{d},volume=0.{d:0>3},afade=t=in:d=0.{d:0>3},afade=t=out:st={d}.{d:0>3}:d={d}.{d:0>3}[mu];", .{ beat_music_input(beats), secs(REEL_MS), millis(MUSIC_VOLUME_PERMILLE), millis(MUSIC_FADE_IN_MS), secs(MUSIC_FADE_OUT_START_MS), millis(MUSIC_FADE_OUT_START_MS), secs(MUSIC_FADE_OUT_MS), millis(MUSIC_FADE_OUT_MS) }); std.debug.print("[vo][mu]amix=inputs=2:normalize=0:duration=first,alimiter=limit=0.{d:0>3}[a]\n", .{millis(LIMIT_PERMILLE)}); } @@ -715,6 +781,177 @@ pub fn beat_solo_ms(b: usize, c0: i32, c1: i32) -> i32 { return REEL_MS - c1 - BEAT_HALF_MS; } +// ---- the beat graph ---- +// The clock's cuts are in ms and fall between frames. A crossfade starts on a frame, so each cut +// takes the last base frame at or before c - BEAT_HALF_MS; both cuts move the same way, so neither +// can take a frame from the other's beat. + +// The base frame where the crossfade for a cut at ms c starts. +pub fn beat_xfade_frame(c: i32) -> i32 { + return @divTrunc((c - BEAT_HALF_MS) * BASE_FPS, 1000); +} + +// How many base frames beat b shows alone, for crossfades starting at frames f0 and f1. The frame a +// crossfade starts on is still the outgoing beat whole; it is counted to neither, so this is a floor. +pub fn beat_solo_frames(b: usize, f0: i32, f1: i32) -> i32 { + if (b == BEAT_OPEN) { + return f0; + } + if (b == BEAT_MAIN) { + return f1 - f0 - TRANSITION_FRAMES; + } + return beat_frames() - f1 - TRANSITION_FRAMES; +} + +// The ffmpeg graph that crossfades the three scene clips, inputs 0 open, 1 main, 2 close, at the +// clock's cuts and trims the result to the reel. Each clip starts at its own frame 0 when its beat +// comes in. xfade starts on the first frame whose time is at or after its offset, so the offset is +// beat_frame_ms(f), which never runs past frame f (the offset test); the timebase is AV_TIME_BASE +// so the offset is not rounded again. +pub fn beat_graph(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { + beat_chain(t, lang, m0, m1, m2); + std.debug.print("[v]\n", .{}); +} + +// The beat graph without its output label, so mode 5 can go on filtering the same stream. +pub fn beat_chain(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { + var b : usize = 0; + while (b < 3) { + std.debug.print("[{d}:v]fps={d},settb=AVTB,format=yuv420p[b{d}];", .{ b, BASE_FPS, b }); + b = b + 1; + } + const d = beat_frame_ms(TRANSITION_FRAMES); + const a0 = beat_frame_ms(beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0))); + const a1 = beat_frame_ms(beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1))); + std.debug.print("[b0][b1]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3}[x0];", .{ secs(d), millis(d), secs(a0), millis(a0) }); + std.debug.print("[x0][b2]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3},trim=end_frame={d},setpts=PTS-STARTPTS", .{ secs(d), millis(d), secs(a1), millis(a1), beat_frames() }); +} + +// The graph's inputs: the picture first (one frame stream, or with beats the three scene clips), +// then one wav per phrase, then the music. +pub fn beat_video_inputs(beats: bool) -> usize { + if (beats) { + return 3; + } + return 1; +} + +pub fn beat_voice_input(beats: bool, p: usize) -> usize { + return beat_video_inputs(beats) + p; +} + +pub fn beat_music_input(beats: bool) -> usize { + return beat_video_inputs(beats) + PHRASES; +} + +// ---- the overlay's windows (t27#10614) ---- +// Crossfade k covers base frames f_k .. f_k + TRANSITION_FRAMES, f_k = beat_xfade_frame(cut k). +// Its first and last frames are the outgoing and the incoming beat whole but for under a ms of +// fade, and both count as crossfade. Beat b's own frames are the rest. In mode 5 an overlay over +// frames lo..hi is on only where that window meets a beat's own frames, one piece per beat. A +// piece shorter than BEAT_OVERLAY_MIN_FRAMES is dropped, not squeezed, unless it is the whole +// overlay: text on for less than a crossfade reads as a flash, and an overlay that was that short +// in mode 3 stays as it was. +pub const BEAT_OVERLAY_MIN_FRAMES : i32 = 15; + +// The first and the last frame of beat b's own frames, for crossfades starting at f0 and f1. +pub fn beat_scene_lo(b: usize, f0: i32, f1: i32) -> i32 { + if (b == BEAT_OPEN) { + return 0; + } + if (b == BEAT_MAIN) { + return f0 + TRANSITION_FRAMES + 1; + } + return f1 + TRANSITION_FRAMES + 1; +} + +pub fn beat_scene_hi(b: usize, f0: i32, f1: i32) -> i32 { + if (b == BEAT_OPEN) { + return f0 - 1; + } + if (b == BEAT_MAIN) { + return f1 - 1; + } + return beat_frames() - 1; +} + +// The frames between(t, a, b) shows, a and b in ms: the first at or after a, the last at or before b. +pub fn beat_frame_from(a: i32) -> i32 { + return @divTrunc(a * BASE_FPS + 999, 1000); +} + +pub fn beat_frame_until(b: i32) -> i32 { + return @divTrunc(b * BASE_FPS, 1000); +} + +fn beat_max(a: i32, b: i32) -> i32 { + if (a > b) { + return a; + } + return b; +} + +// Overlay lo..hi's piece in beat b, and whether it is kept. +pub fn beat_piece_lo(lo: i32, b: usize, f0: i32, f1: i32) -> i32 { + return beat_max(lo, beat_scene_lo(b, f0, f1)); +} + +pub fn beat_piece_hi(hi: i32, b: usize, f0: i32, f1: i32) -> i32 { + return beat_min(hi, beat_scene_hi(b, f0, f1)); +} + +pub fn beat_piece_kept(lo: i32, hi: i32, b: usize, f0: i32, f1: i32) -> bool { + const n = beat_piece_hi(hi, b, f0, f1) - beat_piece_lo(lo, b, f0, f1) + 1; + if (n <= 0) { + return false; + } + return n >= BEAT_OVERLAY_MIN_FRAMES || n == hi - lo + 1; +} + +// How many pieces of overlay lo..hi are kept; none means mode 5 drops it. +pub fn beat_pieces(lo: i32, hi: i32, f0: i32, f1: i32) -> usize { + var k : usize = 0; + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + k = k + 1; + } + b = b + 1; + } + return k; +} + +// The first frame of the first kept piece, where an overlay's own fade or slide starts in mode 5; +// lo when none is kept. +pub fn beat_first_lo(lo: i32, hi: i32, f0: i32, f1: i32) -> i32 { + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + return beat_piece_lo(lo, b, f0, f1); + } + b = b + 1; + } + return lo; +} + +// Mode 5's enable= for overlay lo..hi: exactly its kept pieces' frames. +fn print_pieces(lo: i32, hi: i32, f0: i32, f1: i32) -> void { + std.debug.print(":enable='", .{}); + var first : bool = true; + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + if (first == false) { + std.debug.print("+", .{}); + } + std.debug.print("between(n,{d},{d})", .{ beat_piece_lo(lo, b, f0, f1), beat_piece_hi(hi, b, f0, f1) }); + first = false; + } + b = b + 1; + } + std.debug.print("'", .{}); +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -764,8 +1001,27 @@ pub fn main() -> void { const m1 = env_num("LR_MS1", 0); const m2 = env_num("LR_MS2", 0); if (voice_fits(m0, m1, m2)) { - graph(TALL, t, lang, m0, m1, m2); - graph(WIDE, t, lang, m0, m1, m2); + graph(TALL, t, lang, m0, m1, m2, false); + graph(WIDE, t, lang, m0, m1, m2, false); + } else { + std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); + } + } else if (mode == 4) { + const m0 = env_num("LR_MS0", 0); + const m1 = env_num("LR_MS1", 0); + const m2 = env_num("LR_MS2", 0); + if (voice_fits(m0, m1, m2)) { + beat_graph(t, lang, m0, m1, m2); + } else { + std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); + } + } else if (mode == 5) { + const m0 = env_num("LR_MS0", 0); + const m1 = env_num("LR_MS1", 0); + const m2 = env_num("LR_MS2", 0); + if (voice_fits(m0, m1, m2)) { + graph(TALL, t, lang, m0, m1, m2, true); + graph(WIDE, t, lang, m0, m1, m2, true); } else { std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); } @@ -1104,3 +1360,393 @@ test "control: a cut at fixed thirds of the reel lands inside a spoken phrase" { assert(c0 <= g0); assert(c1 >= k1); } + +// ---- the beat graph's tests ---- + +test "a crossfade starts on the last frame at or before half a crossfade ahead of its cut" { + var c : i32 = BEAT_OPEN_FLOOR_MS; + var n : usize = 0; + while (c <= REEL_MS - REEL_CTA_MIN_MS) { + const f = beat_xfade_frame(c); + // frame f's time, f * 1000 / BASE_FPS, is at or before c - BEAT_HALF_MS; frame f + 1's is after + assert(f * 1000 <= (c - BEAT_HALF_MS) * BASE_FPS); + assert((f + 1) * 1000 > (c - BEAT_HALF_MS) * BASE_FPS); + n = n + 1; + c = c + 1; + } + assert(n == 12251); + assert(beat_xfade_frame(BEAT_OPEN_FLOOR_MS) == MIN_SCENE_FRAMES); +} + +test "the offset printed for frame f selects frame f and no earlier frame" { + var f : i32 = 0; + while (f <= beat_frames()) { + const a = beat_frame_ms(f) * BASE_FPS; + assert(a <= f * 1000); + assert(a > (f - 1) * 1000); + if (f > 0) { + assert(beat_frame_ms(f) > beat_frame_ms(f - 1)); + } + f = f + 1; + } +} + +test "control: an offset rounded up to the next ms misses its frame on two frames in three" { + var missed : usize = 0; + var f : i32 = 0; + while (f <= beat_frames()) { + const up = @divTrunc(f * 1000 + BASE_FPS - 1, BASE_FPS); + if (up * BASE_FPS > f * 1000) { + missed = missed + 1; + } + f = f + 1; + } + // frames 0..450: the 151 multiples of 3 are on a whole ms, the other 300 are not + assert(missed == 300); +} + +test "on whole frames every beat keeps a scene on every voice the clock gives three" { + var checked : usize = 0; + var early : usize = 0; + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + var i : usize = 0; + while (i < 4) { + var j : usize = 0; + while (j < 4) { + var k : usize = 0; + while (k < 5) { + const m0 = BEAT_GRID_M0[i]; + const m1 = BEAT_GRID_M1[j]; + const m2 = BEAT_GRID_M2[k]; + if (voice_fits(m0, m1, m2)) { + const c0 = beat_cut_ms(t, lang, m0, m1, m2, 0); + const c1 = beat_cut_ms(t, lang, m0, m1, m2, 1); + const f0 = beat_xfade_frame(c0); + const f1 = beat_xfade_frame(c1); + assert(f0 >= 0); + assert(f1 + TRANSITION_FRAMES <= beat_frames()); + assert(beat_solo_frames(BEAT_MAIN, f0, f1) >= MIN_SCENE_FRAMES); + assert(beat_solo_frames(BEAT_CLOSE, f0, f1) >= MIN_SCENE_FRAMES); + if (c1 >= BEAT_SPOKEN_MIN_MS) { + assert(beat_solo_frames(BEAT_OPEN, f0, f1) >= MIN_SCENE_FRAMES); + checked = checked + 1; + } else { + assert(beat_solo_frames(BEAT_OPEN, f0, f1) < MIN_SCENE_FRAMES); + early = early + 1; + } + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(checked > 0); + assert(early == TOPICS * 2); +} + +// ---- the overlay's tests ---- + +test "with and without beats every input from the picture to the music is used exactly once" { + var layout : usize = 0; + while (layout < 2) { + const beats = layout == 1; + var used : [8]usize = undefined; + var z : usize = 0; + while (z < 8) { + used[z] = 0; + z = z + 1; + } + var v : usize = 0; + while (v < beat_video_inputs(beats)) { + used[v] = used[v] + 1; + v = v + 1; + } + var p : usize = 0; + while (p < PHRASES) { + used[beat_voice_input(beats, p)] = used[beat_voice_input(beats, p)] + 1; + p = p + 1; + } + used[beat_music_input(beats)] = used[beat_music_input(beats)] + 1; + var i : usize = 0; + while (i <= beat_music_input(beats)) { + assert(used[i] == 1); + i = i + 1; + } + while (i < 8) { + assert(used[i] == 0); + i = i + 1; + } + layout = layout + 1; + } +} + +test "control: without beats the inputs are mode 3's, the frames at 0, the wavs at 1..3, the music at 4" { + assert(beat_video_inputs(false) == 1); + assert(beat_voice_input(false, 0) == 1); + assert(beat_voice_input(false, PHRASES - 1) == 3); + assert(beat_music_input(false) == 4); + assert(beat_voice_input(true, 0) == 3); + assert(beat_music_input(true) == 6); +} + +// ---- the overlay windows' tests (t27#10614) ---- + +// Whether frames lo..hi meet crossfade 0 or 1. +fn on_xfade(lo: i32, hi: i32, f0: i32, f1: i32) -> bool { + if (hi >= f0 && lo <= f0 + TRANSITION_FRAMES) { + return true; + } + return hi >= f1 && lo <= f1 + TRANSITION_FRAMES; +} + +// Every kept piece of lo..hi is inside the window and the reel, off both crossfades, and as long as +// the rule asks; checked against the crossfades themselves, not against beat_scene_*. +fn pieces_clear(lo: i32, hi: i32, f0: i32, f1: i32) -> bool { + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + const a = beat_piece_lo(lo, b, f0, f1); + const z = beat_piece_hi(hi, b, f0, f1); + if (a < lo || z > hi || a < 0 || z >= beat_frames() || z < a) { + return false; + } + if (on_xfade(a, z, f0, f1)) { + return false; + } + if (z - a + 1 < BEAT_OVERLAY_MIN_FRAMES && z - a + 1 != hi - lo + 1) { + return false; + } + } + b = b + 1; + } + return true; +} + +test "the frames mode 5 enables are the frames between(t, a, b) shows" { + // frame f is at f / 30 s: 1000 ms is frame 30, and 1001 ms falls between 30 and 31 + assert(beat_frame_from(0) == 0); + assert(beat_frame_from(1000) == 30); + assert(beat_frame_until(1000) == 30); + assert(beat_frame_from(1001) == 31); + assert(beat_frame_until(1001) == 30); + assert(beat_frame_from(34) == 2); + assert(beat_frame_until(33) == 0); + var f : i32 = 0; + while (f < beat_frames()) { + // frame f's time in us, against an edge a ms: shown from a exactly when the frame is not early + var a : i32 = 0; + while (a <= 100) { + const ms = 100 * @divTrunc(f, 3) + a; + const shown = f * 1000000 >= ms * 1000 * BASE_FPS; + assert(shown == (f >= beat_frame_from(ms))); + a = a + 17; + } + f = f + 1; + } +} + +test "an overlay with no room in its scene is dropped, not squeezed" { + const f0 : i32 = 100; + const f1 : i32 = 300; + // the beats' own frames: 0..99, 116..299, 316..449 + assert(beat_scene_hi(BEAT_OPEN, f0, f1) == 99); + assert(beat_scene_lo(BEAT_MAIN, f0, f1) == 116); + assert(beat_scene_hi(BEAT_MAIN, f0, f1) == 299); + assert(beat_scene_lo(BEAT_CLOSE, f0, f1) == 316); + assert(beat_scene_hi(BEAT_CLOSE, f0, f1) == 449); + // wholly inside a crossfade: dropped + assert(beat_pieces(102, 110, f0, f1) == 0); + // across a crossfade with five frames on each side: both pieces are too short, so dropped + assert(beat_pieces(95, 120, f0, f1) == 0); + // five frames clear of every crossfade: left as it was + assert(beat_pieces(95, 99, f0, f1) == 1); + assert(beat_piece_lo(95, BEAT_OPEN, f0, f1) == 95); + // across a crossfade with room on both sides: two pieces, each cut at the crossfade + assert(beat_pieces(80, 140, f0, f1) == 2); + assert(beat_piece_hi(140, BEAT_OPEN, f0, f1) == 99); + assert(beat_first_lo(80, 140, f0, f1) == 80); + assert(beat_piece_lo(80, BEAT_MAIN, f0, f1) == 116); + // the short side alone is dropped and the long one starts the overlay + assert(beat_pieces(95, 160, f0, f1) == 1); + assert(beat_first_lo(95, 160, f0, f1) == 116); + assert(pieces_clear(95, 160, f0, f1)); +} + +test "control: mode 3's windows over mode 4's cuts put the goal's first caption on a crossfade" { + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const f0 = beat_xfade_frame(c0); + const f1 = beat_xfade_frame(beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1)); + const lo = beat_frame_from(chunk_from(0, 0, M_EN0, M_EN1, M_EN2, 1, 0)); + const hi = beat_frame_until(chunk_until(0, 0, M_EN0, M_EN1, M_EN2, 1, 0)); + // the goal is spoken from cut 0, the middle of crossfade 0 + assert(on_xfade(lo, hi, f0, f1)); + assert(lo > f0); + assert(lo < f0 + TRANSITION_FRAMES); + // mode 5 shows it from the main beat's first own frame + assert(beat_pieces(lo, hi, f0, f1) == 1); + assert(beat_first_lo(lo, hi, f0, f1) == f0 + TRANSITION_FRAMES + 1); +} + +test "in mode 5 no caption, tag or address is on a crossfade, every lesson, both languages, every voice the clock accepts" { + var voices : usize = 0; + var windows : usize = 0; + var crossing : usize = 0; + var dropped : usize = 0; + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + var i : usize = 0; + while (i < 4) { + var j : usize = 0; + while (j < 4) { + var k : usize = 0; + while (k < 5) { + const m0 = BEAT_GRID_M0[i]; + const m1 = BEAT_GRID_M1[j]; + const m2 = BEAT_GRID_M2[k]; + if (voice_fits(m0, m1, m2)) { + voices = voices + 1; + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + var s : usize = 0; + while (s < SHOTS) { + const lo = SHOT_START[s]; + const hi = SHOT_START[s + 1] - 1; + assert(pieces_clear(lo, hi, f0, f1)); + windows = windows + 1; + if (on_xfade(lo, hi, f0, f1)) { + crossing = crossing + 1; + } + if (beat_pieces(lo, hi, f0, f1) == 0) { + dropped = dropped + 1; + } + s = s + 1; + } + var p : usize = 0; + while (p < phrases_for(m0, m1, m2)) { + var c : usize = 0; + while (c < chunks(phrase_item(t, lang, p))) { + const lo = beat_frame_from(chunk_from(t, lang, m0, m1, m2, p, c)); + const hi = beat_frame_until(chunk_until(t, lang, m0, m1, m2, p, c)); + assert(pieces_clear(lo, hi, f0, f1)); + windows = windows + 1; + if (on_xfade(lo, hi, f0, f1)) { + crossing = crossing + 1; + } + if (beat_pieces(lo, hi, f0, f1) == 0) { + dropped = dropped + 1; + } + c = c + 1; + } + p = p + 1; + } + const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); + assert(pieces_clear(lo, beat_frames() - 1, f0, f1)); + // the address is on in the close beat alone, to the reel's last frame + assert(beat_pieces(lo, beat_frames() - 1, f0, f1) == 1); + assert(beat_first_lo(lo, beat_frames() - 1, f0, f1) == beat_scene_lo(BEAT_CLOSE, f0, f1)); + windows = windows + 1; + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(voices > 0); + assert(windows > voices * SHOTS); + // mode 3's windows do meet the crossfades: the rule has work to do + assert(crossing > voices); + assert(dropped < windows); +} + +test "the cost on lesson 1's measured voice: a caption across a crossfade with no room is dropped, and every phrase keeps one" { + var dropped : usize = 0; + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, M_EN0, M_EN1, M_EN2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, M_EN0, M_EN1, M_EN2, 1)); + var p : usize = 0; + while (p < phrases_for(M_EN0, M_EN1, M_EN2)) { + var kept : usize = 0; + var c : usize = 0; + while (c < chunks(phrase_item(t, lang, p))) { + const lo = beat_frame_from(chunk_from(t, lang, M_EN0, M_EN1, M_EN2, p, c)); + const hi = beat_frame_until(chunk_until(t, lang, M_EN0, M_EN1, M_EN2, p, c)); + if (beat_pieces(lo, hi, f0, f1) == 0) { + // only a caption a crossfade cuts is ever dropped + assert(on_xfade(lo, hi, f0, f1)); + dropped = dropped + 1; + } else { + kept = kept + 1; + } + c = c + 1; + } + assert(kept >= 1); + p = p + 1; + } + lang = lang + 1; + } + t = t + 1; + } + // crossfade 0 is centred on the goal's start, so it begins under the title's last words + assert(dropped > 0); +} + +test "the cost: on a voice the reel speeds up, mode 5 holds the address for less than REEL_CTA_MIN_MS" { + var short : usize = 0; + var least : i32 = beat_frames(); + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + var i : usize = 0; + while (i < 4) { + var j : usize = 0; + while (j < 4) { + var k : usize = 0; + while (k < 5) { + const m0 = BEAT_GRID_M0[i]; + const m1 = BEAT_GRID_M1[j]; + const m2 = BEAT_GRID_M2[k]; + if (voice_fits(m0, m1, m2)) { + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); + const held = beat_frames() - beat_first_lo(lo, beat_frames() - 1, f0, f1); + if (held < least) { + least = held; + } + if (held * 1000 < REEL_CTA_MIN_MS * BASE_FPS) { + short = short + 1; + } + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(short > 0); + // never less than the close beat's own frames: its solo floor less the crossfade's last frame + assert(least >= MIN_SCENE_FRAMES - 1); +}