From 1daf310faf4292271f280b9ff7b109d7a297c097 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:33:45 +0700 Subject: [PATCH 1/7] t27f: beat clock -- the reel's three beats cut where the voice changes, crossfaded (Closes #10596) R4a of epic #10560. lesson_reel.t27 gains a beat clock: cut 0 at the goal phrase's start (held to a 1250 ms open floor), cut 1 at cta_from, each a crossfade of reel.t27's TRANSITION_FRAMES centred on the cut. Seven tests: frame constants, lesson 1's measured cuts, the short-title floor, a grid of 80 voices x every lesson x both languages (full clock, voice too short for an open scene, refused), weights summing to 1000 at every 10 ms, exactly two beat changes over 450 frames, and a fixed-thirds control that lands mid-phrase. The clock lives in lesson_reel.t27, not in the beat_clock.t27 module the issue names: importing phrase_start or cta_from into another module leaves them UNRESOLVED because token_floor_mux.t27 declares the same names, and the splicer drops ambiguous imports even on an explicit path (#10556). Lab: 13/13 tests pass, 0 vacuous; seal saved and verified. t27b still blocked on type [N]T, as the ledger row already says; max_not_pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 20 +- specs/t27f/lesson_reel.t27 | 287 ++++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 10 deletions(-) diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 490e46ad7c..5a1643fc9e 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:6a381e722c19647a8f05c723633503d1d931b143538c6183f8832fec577ebeb7", + "gen_hash_rust": "sha256:bf7b0432da8ec2e1bcf0f139aa271e9df74986d67c81dc1bb0bcacd19357d1db", + "gen_hash_verilog": "sha256:836331bc2320ec6d80ce1580e30f1d3a9cc9fbd89325995f141c090282a68ec7", + "gen_hash_zig": "sha256:50a202697595b03abac33b3707f25a239343ae7aa2256b0c43fec31b950cf61b", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T15:14:32Z", + "sealed_at": "2026-10-18T18:33:33Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:0c527e6415d5ffe600cd425db2456e1691e47bcaaf69cf5af0b856bb8bfec66e", + "spec_hash": "sha256:87165b892aaaba0647fe1657669524eca65c31547f11f4e4994abb830c673b0e", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 6, - "total": 6, + "passed": 13, + "total": 13, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index 29726fc24b..b6a0455048 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -24,9 +24,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. The beat clock is not yet wired into the graph: the frames that +// follow it are R4b. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -95,6 +107,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. @@ -598,6 +613,108 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void 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; +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -821,3 +938,169 @@ 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); +} From 0abe94638bb05a9b4dfef6a94565946e20834bf5 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:35:09 +0700 Subject: [PATCH 2/7] t27f skill: the splicer trap that kept the beat clock inside lesson_reel (Refs #10596) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 187a88c8ab..9fcccf74ae 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -73,6 +73,14 @@ 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. + ## 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 From 8aa6f917877af70de08d83dc1a8f60b9a52d64ac Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:46:29 +0700 Subject: [PATCH 3/7] t27f: beat graph -- the reel's three scenes crossfaded at the beat clock's cuts, on whole frames (Closes #10603) lesson_reel.t27 gains: - beat_xfade_frame: the last base frame at or before c - BEAT_HALF_MS. - beat_solo_frames: a floor on how many frames each beat shows alone. - beat_graph: inputs 0..2 (the open, main and close scene clips), crossfaded with xfade at offset beat_frame_ms(f) in AV_TIME_BASE, then trimmed to 450 frames. - LR_MODE=4, which prints the graph and refuses a voice that does not fit, as mode 3 does. Tests: 17/17 pass, 0 vacuous. - New: every cut from 1250 to 13500 ms lands on its frame. - New: the offset for every frame 0..450 selects that frame. - New control: an offset rounded up misses 300 of 451 frames. - New: on R4a's voice grid, every beat keeps MIN_SCENE_FRAMES whole frames, except the counted early case. Measured with ffmpeg 5.1 on the Railway lab, on solid-colour inputs, 450 frames each: - 1300/4200/6800 ms: runs 50 / 332 / 38. - 2500/8000/9000 ms: runs 88 / 235 / 98. - 200/1500/13100 ms: runs 28 / 30 / 362. - 9000/9000/9000 ms: refused. Every run equals beat_solo_frames, with one extra frame when the offset falls on a whole ms. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 16 +-- specs/t27f/lesson_reel.t27 | 146 ++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 5a1643fc9e..ae4cace0cf 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,23 +31,23 @@ } ], "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:45fe54cd4be6d62ca81d3decf493c9999a451c7bc4149e49f86c0edc676e6265", + "gen_hash_rust": "sha256:1b2673482a4af8a938ef6146a88f87aa619a7e4e4d5b9c1155b91cf20eb65e01", + "gen_hash_verilog": "sha256:00b75242dd4b9b49a1bbb2720d3a7b817dd8715d1d8a6f36b80cdf83145f0a04", + "gen_hash_zig": "sha256:a45ecc01256a49e9d61781595043045a7ff98f34abfe22df6ae3b6bb8f7dd8a9", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T18:33:33Z", + "sealed_at": "2026-10-18T18:44:43Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:87165b892aaaba0647fe1657669524eca65c31547f11f4e4994abb830c673b0e", + "spec_hash": "sha256:69663c90a7ad61dbdf5a017d97f28d2dc0c885dae0643a361d43bbae31787a9a", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 13, - "total": 13, + "passed": 17, + "total": 17, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index b6a0455048..424f04e743 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -15,6 +15,8 @@ // 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 // 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 +39,8 @@ // 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 4 cuts the three scenes but lays nothing over them: mode 3's +// captions and address on top of the beat video are R4c, and the scenes themselves are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -715,6 +717,46 @@ 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 { + 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[v]\n", .{ secs(d), millis(d), secs(a1), millis(a1), beat_frames() }); +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -769,6 +811,15 @@ pub fn main() -> void { } 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 { std.debug.print("{d}\n", .{files(t, lang)}); } @@ -1104,3 +1155,94 @@ 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); +} From 20a9a6a6f7080d8daa7088cb8d9867fa9d207639 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:47:47 +0700 Subject: [PATCH 4/7] t27f skill: cut video on a frame and check it by counting solid-colour runs (Refs #10603) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 9fcccf74ae..0d529a6abd 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -81,6 +81,25 @@ 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`. + ## 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 From 0d4ad98f779006a9e585b3a2d8fa103ddd85b6e8 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:01:58 +0700 Subject: [PATCH 5/7] t27f: lay the reel's captions, cards and address over the beat video, LR_MODE=5 (Closes #10609) lesson_reel.t27: - graph() takes a beats flag. With it, the picture is the beat chain over inputs 0..2 instead of input 0, and the voice and music inputs move past the three clips (3..5 and 6). - beat_graph() is now beat_chain() plus its [v] label, so mode 5 goes on filtering the same stream. - beat_video_inputs / beat_voice_input / beat_music_input number the inputs. - LR_MODE=5 prints both orientations and refuses a voice that does not fit. Tests: 19/19 pass, 0 vacuous. New: every input from the picture to the music is used exactly once in both layouts. New control: without beats the inputs are mode 3's. Regression on the Railway lab: 96 graphs (4 lessons, 2 languages, 4 voices; mode 3 against master and the beat-graph branch, mode 4 against the beat-graph branch) are byte-identical. Mode 5 rendered with ffmpeg 5.1 from solid-colour clips, sine phrases and music. Tall and wide each give 450 frames and 15.000 s of audio. Pixels outside the text run 50 red, 332 green and 38 blue frames, which equals beat_solo_frames for 1300/4200/6800 ms. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 16 ++-- specs/t27f/lesson_reel.t27 | 105 ++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index ae4cace0cf..02c9158cdc 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:45fe54cd4be6d62ca81d3decf493c9999a451c7bc4149e49f86c0edc676e6265", - "gen_hash_rust": "sha256:1b2673482a4af8a938ef6146a88f87aa619a7e4e4d5b9c1155b91cf20eb65e01", - "gen_hash_verilog": "sha256:00b75242dd4b9b49a1bbb2720d3a7b817dd8715d1d8a6f36b80cdf83145f0a04", - "gen_hash_zig": "sha256:a45ecc01256a49e9d61781595043045a7ff98f34abfe22df6ae3b6bb8f7dd8a9", + "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-18T18:44:43Z", + "sealed_at": "2026-10-18T18:59:43Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:69663c90a7ad61dbdf5a017d97f28d2dc0c885dae0643a361d43bbae31787a9a", + "spec_hash": "sha256:714fad38cc4f8d4a1d31544730ce7a5678eab133c825ecd89fdaa5352134c612", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 17, - "total": 17, + "passed": 19, + "total": 19, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index 424f04e743..b405ddfc5a 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -17,6 +17,8 @@ // 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). // @@ -39,8 +41,8 @@ // 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. Mode 4 cuts the three scenes but lays nothing over them: mode 3's -// captions and address on top of the beat video are R4c, and the scenes themselves are R3. +// 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; @@ -566,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) { @@ -602,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; @@ -611,7 +620,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)}); } @@ -745,6 +754,12 @@ pub fn beat_solo_frames(b: usize, f0: i32, f1: i32) -> i32 { // 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 }); @@ -754,7 +769,24 @@ pub fn beat_graph(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { 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[v]\n", .{ secs(d), millis(d), secs(a1), millis(a1), beat_frames() }); + 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 ---- @@ -806,8 +838,8 @@ 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", .{}); } @@ -820,6 +852,16 @@ pub fn main() -> void { } 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", .{}); + } } else { std.debug.print("{d}\n", .{files(t, lang)}); } @@ -1246,3 +1288,48 @@ test "on whole frames every beat keeps a scene on every voice the clock gives th 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); +} From 092db843bb43fa298876a73ed71d6357243e7dc5 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:03:23 +0700 Subject: [PATCH 6/7] t27f skill: add a graph mode without moving the old ones, prove it with cmp, and three lab traps (Refs #10609) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 0d529a6abd..09ff941fc6 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -100,6 +100,19 @@ The runs are the scene lengths and must equal the spec's frame counts. Expect on 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 From d8c4c28630182ff363220ea0f011ef238b39f41b Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:17:29 +0700 Subject: [PATCH 7/7] skills: t27/reel-beat card -- TRI CLAW cuts a lesson reel on the beat clock (Refs #10609) specs/skills/t27-reel-beat.t27 is a spec-only skill card, in the same shape as the four t27-social-visual cards from #10570. It points at specs/t27f/lesson_reel.t27 (beat_* functions, LR_MODE=4 and LR_MODE=5) and copies none of it. It does not cite reel_factory.t27: lesson_reel does not read the scene plan yet, and the card says so. Railway lab: 1/1 test, 9 runtime asserts, 0 vacuous. Seal saved and verified. t27b's front end accepts it (x86 lab: JIT-only CRASH, as for t27-self-host), so the ledger row is pass. The ledger row sits between providers-steward and self-host. That keeps it one line away from the four rows #10613 adds after self-host, so the two PRs do not conflict. Co-Authored-By: Claude Opus 5.5 --- .../seals/skills_skill_t27_reel_beat.json | 28 +++++++++++ docs/reports/t27b_expectations.json | 1 + specs/skills/t27-reel-beat.t27 | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .trinity/seals/skills_skill_t27_reel_beat.json create mode 100644 specs/skills/t27-reel-beat.t27 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/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"; +}