diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 187a88c8ab..09ff941fc6 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -73,6 +73,46 @@ the reels. Describing T27F as #1677 is the error this section exists to stop (ow When asked "how is T27F going", report against the core rows first. Progress on the applications is not progress on the core. +**Trap: a name declared in two specs cannot be imported (t27#10556).** Measured on t27c 0.5.2: +`use t27f::lesson_reel::phrase_start` returns `UNRESOLVED ... ambiguous, not spliced`, because +`token_floor_mux.t27` declares a private `fn phrase_start`. The same happens to `cta_from` and +`voice_fits`. The explicit path does not help. Before you plan a new module on top of a reel spec, +grep `specs/` for every name it would import. If any of them collides, put the new code inside the +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. + ## 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 490e46ad7c..02c9158cdc 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -5,6 +5,10 @@ "spec": "specs/automation/owner_voice_reel.t27", "spec_hash": "sha256:76c0df8e57686a90b3bce85bcb0148cbed7fd05da724b217ef53fa468664f151" }, + { + "spec": "specs/t27f/reel.t27", + "spec_hash": "sha256:c47b21cea1d7169da6528e321cf2d2c513d3f552c0cd04fe9d37bbd5314ccacf" + }, { "spec": "specs/t27f/reel_marketing.t27", "spec_hash": "sha256:ab07f6476d900cc0e089ba1d851f0b610a77b778f00fd906d1b98a29281941c7" @@ -27,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:74d5f5d8fad98ead782f6c26309ed349da58471650642503a13a8e32cb7427ae", - "gen_hash_rust": "sha256:ccc5014bd6bf09ba55b21e4d5bd5fb80383c36cce4d83468812bdd4fadd04835", - "gen_hash_verilog": "sha256:f956226ada7459fc5d856060971f9160ab41ee78b1a5dbf713ad8531e3d41abd", - "gen_hash_zig": "sha256:42635d596d160c7c49521929d8e4f8f640f89a3180b5e1ac118c0177c4d14545", + "gen_hash_c": "sha256:6cf89f561d2471d4ea92d3f00e18aac2a2587b2788098e8d7e552ec83de63dbe", + "gen_hash_rust": "sha256:f6920ee461def04b74ff3634e89c24b41717f1ad44b8dfdc94a16f883ca9d5a3", + "gen_hash_verilog": "sha256:2694b8d7796169dfba074e585072006e53d2230160d1fba51094c4819a653e8c", + "gen_hash_zig": "sha256:b1479ef2a482771dd7f65dbc217a6e92afe1e49ed06a10b009329a8ec6265d6d", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T15:14:32Z", + "sealed_at": "2026-10-18T18:59:43Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:0c527e6415d5ffe600cd425db2456e1691e47bcaaf69cf5af0b856bb8bfec66e", + "spec_hash": "sha256:714fad38cc4f8d4a1d31544730ce7a5678eab133c825ecd89fdaa5352134c612", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 6, - "total": 6, + "passed": 19, + "total": 19, "vacuous": 0 }, "toolchain": { diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 09b79809ce..8a45a8567b 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -1406,6 +1406,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 29726fc24b..b405ddfc5a 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -15,6 +15,10 @@ // 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 // 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). // @@ -24,9 +28,21 @@ // title and the goal. A caption chunk is a run of whole words up to CHUNK_CHARS, timed by its // share of its phrase's letters -- an estimate, not a word-level alignment. // +// BEATS (t27#10596, R4a of t27#10560): reel_beats.t27 gives every reel three scenes, open, main +// and close; the beat clock below says when each is on screen. Cut 0 is the goal phrase's start +// and cut 1 is where the address takes over, so the picture turns with the sentence rather than at +// fixed thirds of the reel, which on lesson 1 land inside a spoken phrase (the control test). A +// title too short to read holds the open beat to BEAT_OPEN_FLOOR_MS, unless the voice reaches the +// address before BEAT_SPOKEN_MIN_MS: then the open beat gives way and the main beat keeps its +// scene (one point on the test grid, 200/1500/13100 ms, where the task is dropped). Every cut is a crossfade of +// reel.t27's TRANSITION_FRAMES, centred on the cut. The clock lives here, not in a module of its +// own, because a module that imports phrase_start, cta_from or voice_fits from this file gets +// none of them: t27c's splicer finds a second declaration of each in this file's imports and +// 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. A lesson-specific scene (the spec as a T27F diagram) is the next -// step and is not here. +// labels are the physics reel's. Mode 5 lays the same captions and address over every scene, in +// the same places; nothing on top changes with the beat. The scenes themselves are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -95,6 +111,9 @@ use t27f::reel_topics::code_lines; use t27f::reel_topics::line_len; use t27f::reel_topics::code_byte; use t27f::reel_topics::lesson_n; +use t27f::reel::BASE_FPS; +use t27f::reel::TRANSITION_FRAMES; +use t27f::reel::MIN_SCENE_FRAMES; pub const ID : str = "lesson-reel"; // JetBrains Mono ExtraBold with both scripts and the digits, so one face sets every line. @@ -549,8 +568,15 @@ fn print_cta(o: usize, t: usize, lang: usize, a: i32) -> void { 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) }); } -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 s : usize = 0; while (s < SHOTS) { @@ -585,7 +611,7 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void 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; @@ -594,10 +620,175 @@ 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)}); } +// ---- the beat clock ---- +// Which of the reel's three beats is on screen at ms x, and how much of each when two crossfade. +// The middle beat's weight is computed on its own, not as the rest of 1000, so the test that the +// three sum to 1000 can fail: it fails when two crossfades overlap. + +pub const BEAT_OPEN : usize = 0; +pub const BEAT_MAIN : usize = 1; +pub const BEAT_CLOSE : usize = 2; + +// The crossfade and the shortest time a beat is alone on screen, in ms: reel.t27's frame counts +// at its frame rate, which the first beat test checks. +pub const BEAT_XFADE_MS : i32 = 500; +pub const BEAT_HALF_MS : i32 = 250; +pub const BEAT_MIN_SOLO_MS : i32 = 1000; +// The earliest cut 0 may fall: the open beat alone for BEAT_MIN_SOLO_MS, then half the crossfade. +pub const BEAT_OPEN_FLOOR_MS : i32 = 1250; + +// The time of base frame f, and how many base frames the reel has. +pub fn beat_frame_ms(f: i32) -> i32 { + return @divTrunc(f * 1000, BASE_FPS); +} + +pub fn beat_frames() -> i32 { + return @divTrunc(REEL_MS * BASE_FPS, 1000); +} + +// The shortest time from the reel's start to the address that leaves every beat a scene of its own: +// the open floor, a crossfade, and the main beat alone for BEAT_MIN_SOLO_MS. +pub const BEAT_SPOKEN_MIN_MS : i32 = 2750; + +// Cut k of lesson t's reel in language lang: 0 open to main, 1 main to close. Cut 0 is the goal's +// start, held to the open floor, but never so late that the main beat loses its scene: when the +// voice reaches the address before BEAT_SPOKEN_MIN_MS the open beat gives way, not the main one. +pub fn beat_cut_ms(t: usize, lang: usize, m0: i32, m1: i32, m2: i32, k: usize) -> i32 { + const c1 = cta_from(t, lang, m0, m1, m2); + if (k == 0) { + var s : i32 = phrase_start(m0, m1, m2, 1); + if (s < BEAT_OPEN_FLOOR_MS) { + s = BEAT_OPEN_FLOOR_MS; + } + const last = c1 - BEAT_XFADE_MS - BEAT_MIN_SOLO_MS; + if (s > last) { + s = last; + } + return s; + } + return c1; +} + +// How far the incoming beat has faded in at ms x for a cut at c, per mille. +pub fn beat_ramp(x: i32, c: i32) -> i32 { + const a = c - BEAT_HALF_MS; + if (x <= a) { + return 0; + } + if (x >= a + BEAT_XFADE_MS) { + return 1000; + } + return @divTrunc((x - a) * 1000, BEAT_XFADE_MS); +} + +fn beat_min(a: i32, b: i32) -> i32 { + if (a < b) { + return a; + } + return b; +} + +// Beat b's weight at ms x, per mille, for cuts c0 and c1. +pub fn beat_weight(b: usize, x: i32, c0: i32, c1: i32) -> i32 { + if (b == BEAT_OPEN) { + return 1000 - beat_ramp(x, c0); + } + if (b == BEAT_MAIN) { + return beat_min(beat_ramp(x, c0), 1000 - beat_ramp(x, c1)); + } + return beat_ramp(x, c1); +} + +// The beat a still at ms x shows: the one after the last cut reached. At a cut both weigh 500 and +// the incoming beat wins. +pub fn beat_at(x: i32, c0: i32, c1: i32) -> usize { + if (x < c0) { + return BEAT_OPEN; + } + if (x < c1) { + return BEAT_MAIN; + } + return BEAT_CLOSE; +} + +// How long beat b is alone on screen (weight 1000), in ms. +pub fn beat_solo_ms(b: usize, c0: i32, c1: i32) -> i32 { + if (b == BEAT_OPEN) { + return c0 - BEAT_HALF_MS; + } + if (b == BEAT_MAIN) { + return c1 - c0 - BEAT_XFADE_MS; + } + 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; +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -647,8 +838,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", .{}); } @@ -821,3 +1031,305 @@ test "the files are numbered in order, and the tags read in upper case" { t = t + 1; } } + +// ---- the beat clock's tests ---- + +// Phrase lengths the grid test sweeps: a title from barely a word to long, a goal from short to +// one the voice rule refuses after a long title (14000), a task from short to one the reel drops +// (13100 after a 200 ms title and a 1500 ms goal leaves the voice at the address by 2700 ms). +pub const BEAT_GRID_M0 : [4]i32 = [200, 700, 1300, 2500]; +pub const BEAT_GRID_M1 : [4]i32 = [1500, 4200, 8000, 14000]; +pub const BEAT_GRID_M2 : [5]i32 = [800, 4000, 6800, 9000, 13100]; + +test "the crossfade and the floor are the reel's own frame counts" { + assert(BEAT_XFADE_MS == beat_frame_ms(TRANSITION_FRAMES)); + assert(BEAT_MIN_SOLO_MS == beat_frame_ms(MIN_SCENE_FRAMES)); + assert(2 * BEAT_HALF_MS == BEAT_XFADE_MS); + assert(BEAT_OPEN_FLOOR_MS == BEAT_MIN_SOLO_MS + BEAT_HALF_MS); + assert(beat_frames() == 450); +} + +test "on lesson 1's measured voice the cuts fall where the sentence changes" { + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + assert(c0 == phrase_start(M_EN0, M_EN1, M_EN2, 1)); + assert(c0 > BEAT_OPEN_FLOOR_MS); + assert(c1 == cta_from(0, 0, M_EN0, M_EN1, M_EN2)); + // the open beat ends after the title is spoken, and the close starts after the task is + assert(c0 > phrase_start(M_EN0, M_EN1, M_EN2, 0) + played(M_EN0, M_EN1, M_EN2, 0)); + assert(c1 >= phrase_start(M_EN0, M_EN1, M_EN2, 2) + played(M_EN0, M_EN1, M_EN2, 2)); +} + +test "a title too short to read holds the open beat to the floor" { + assert(voice_fits(200, 4200, 6800)); + assert(phrase_start(200, 4200, 6800, 1) < BEAT_OPEN_FLOOR_MS); + const c0 = beat_cut_ms(0, 0, 200, 4200, 6800, 0); + const c1 = beat_cut_ms(0, 0, 200, 4200, 6800, 1); + assert(c0 == BEAT_OPEN_FLOOR_MS); + assert(beat_solo_ms(BEAT_OPEN, c0, c1) == BEAT_MIN_SOLO_MS); +} + +test "every beat is alone on screen for a scene's length, every lesson, both languages, every voice on the grid" { + var checked : usize = 0; + var early : usize = 0; + var refused : 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); + // the main and close beats keep their scene on every voice the rule takes + assert(beat_solo_ms(BEAT_MAIN, c0, c1) >= BEAT_MIN_SOLO_MS); + assert(beat_solo_ms(BEAT_CLOSE, c0, c1) >= BEAT_MIN_SOLO_MS); + assert(c0 >= phrase_start(m0, m1, m2, 1) || c1 < BEAT_SPOKEN_MIN_MS); + if (c1 >= BEAT_SPOKEN_MIN_MS) { + assert(beat_solo_ms(BEAT_OPEN, c0, c1) >= BEAT_MIN_SOLO_MS); + checked = checked + 1; + } else { + // a voice at the address this early cannot give the open beat a scene too + assert(beat_solo_ms(BEAT_OPEN, c0, c1) < BEAT_MIN_SOLO_MS); + early = early + 1; + } + } else { + refused = refused + 1; + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + // the grid reaches all three outcomes: a full clock, a voice too short for an open scene, and + // a voice the rule refuses; the short case is one grid point, 200/1500/13100, per lesson + assert(checked > 0); + assert(refused > 0); + assert(early == TOPICS * 2); + assert(checked + early + refused == TOPICS * 2 * 80); +} + +test "the three weights sum to a whole at every 10 ms and each beat fades one way" { + var sel : usize = 0; + var seen : [3]usize = [0, 0, 0]; + while (sel < 2) { + var m0 : i32 = M_EN0; + if (sel == 1) { + m0 = 200; + } + const c0 = beat_cut_ms(0, 0, m0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, m0, M_EN1, M_EN2, 1); + assert(beat_weight(BEAT_OPEN, c0, c0, c1) == 500); + assert(beat_weight(BEAT_MAIN, c0, c0, c1) == 500); + assert(beat_weight(BEAT_MAIN, c1, c0, c1) == 500); + assert(beat_weight(BEAT_CLOSE, c1, c0, c1) == 500); + var last0 : i32 = 1000; + var last2 : i32 = 0; + var lastb : usize = BEAT_OPEN; + var x : i32 = 0; + while (x <= REEL_MS) { + const w0 = beat_weight(BEAT_OPEN, x, c0, c1); + const w1 = beat_weight(BEAT_MAIN, x, c0, c1); + const w2 = beat_weight(BEAT_CLOSE, x, c0, c1); + assert(w0 + w1 + w2 == 1000); + assert(w0 <= last0); + assert(w2 >= last2); + const b = beat_at(x, c0, c1); + assert(b >= lastb); + // the beat a still shows is never the one that has faded out + assert(beat_weight(b, x, c0, c1) >= 500); + seen[b] = seen[b] + 1; + last0 = w0; + last2 = w2; + lastb = b; + x = x + 10; + } + sel = sel + 1; + } + assert(seen[0] > 0); + assert(seen[1] > 0); + assert(seen[2] > 0); +} + +test "over the reel's 450 frames the picture changes beat exactly twice" { + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + var changes : usize = 0; + var last : usize = BEAT_OPEN; + var f : i32 = 0; + while (f < beat_frames()) { + const b = beat_at(beat_frame_ms(f), c0, c1); + if (b != last) { + changes = changes + 1; + } + last = b; + f = f + 1; + } + assert(changes == 2); + assert(last == BEAT_CLOSE); +} + +test "control: a cut at fixed thirds of the reel lands inside a spoken phrase" { + const third = @divTrunc(REEL_MS, 3); + const g0 = phrase_start(M_EN0, M_EN1, M_EN2, 1); + const g1 = g0 + played(M_EN0, M_EN1, M_EN2, 1); + const k0 = phrase_start(M_EN0, M_EN1, M_EN2, 2); + const k1 = k0 + played(M_EN0, M_EN1, M_EN2, 2); + // the first third falls inside the goal, the second inside the task + assert(third > g0 && third < g1); + assert(2 * third > k0 && 2 * third < k1); + // the voice clock's cuts fall on no spoken word + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + 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); +}