From 6ddb9b47c3012f761f16b43108ef343611bf9402 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 18 Oct 2026 20:23:49 +0700 Subject: [PATCH 1/3] t27f token_floor: drone flights instead of hard cuts, text in the platforms' safe zone, a marketing clock (Closes #10469) The owner on the #10451 reels: the text runs over itself, the transitions are too jerky (fly like a drone camera, as in The Matrix), and the reel should work as marketing. - reel_marketing.t27 (new): seven measured overlay sources and the safe rect taking the widest margin of each edge, the hook clock, the reset window, the CTA hold and size, the 15-30 s length; sources listed. - token_floor_scenes: each shot after the first opens with a flight -- a quintic Hermite in ln z and one in the screen px the centre sweeps, matching the drifts at both ends, a pull-back for long flights and a hover. Worst shove 20.1 px/frame^2 tall, 19.9 wide (bound 24); a hard cut would throw it more than 300. Tall layout inside reel_safe. - token_floor: a label leaving the scene's safe rect fades whole. - token_floor_mux: hook, CTA and safe-zone tests against reel_marketing. - clamp01 written once (pbr_sphere), so duplicate-bodies is green; the t27b ledger names pbr_sphere (pass) and token_floor (blocked, type [N]T), cap 74 -> 75 for the one new non-pass spec. Lab: test-report 0 FAIL, 0 vacuous on all five; seals rewritten; both reels 15.000 s at -14.0 LUFS; four tall shards equal the full render. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_pbr_sphere.json | 6 +- .trinity/seals/t27f_reel_marketing.json | 28 ++ .trinity/seals/t27f_token_floor.json | 20 +- .trinity/seals/t27f_token_floor_mux.json | 24 +- .trinity/seals/t27f_token_floor_scenes.json | 27 +- docs/reports/t27b_expectations.json | 4 +- specs/t27f/pbr_sphere.t27 | 2 +- specs/t27f/reel_marketing.t27 | 151 +++++++ specs/t27f/token_floor.t27 | 112 ++++-- specs/t27f/token_floor_mux.t27 | 133 +++++-- specs/t27f/token_floor_scenes.t27 | 421 ++++++++++++++++---- 11 files changed, 758 insertions(+), 170 deletions(-) create mode 100644 .trinity/seals/t27f_reel_marketing.json create mode 100644 specs/t27f/reel_marketing.t27 diff --git a/.trinity/seals/t27f_pbr_sphere.json b/.trinity/seals/t27f_pbr_sphere.json index 9c9e346aca..5951246335 100644 --- a/.trinity/seals/t27f_pbr_sphere.json +++ b/.trinity/seals/t27f_pbr_sphere.json @@ -5,13 +5,13 @@ "gen_hash_c": "sha256:2f31d565d2c71e2d4d92894255a9bdef3222ac82ef1c7e49bd7245e1d203abb9", "gen_hash_rust": "sha256:e945517759c860b4ab1c45030cccc81125b900fe852ee9f9f386872af106be99", "gen_hash_verilog": "sha256:9196306f8144111fba605ad93b133846b49bc3130ca9e55dda246fc6581f04aa", - "gen_hash_zig": "sha256:a8856def731456b38f7486e526568c75f93c961eddd33185716419218458b58d", + "gen_hash_zig": "sha256:95eae42a6dbf95408dc9b1fe73c89297afeb411ee60316516350bdadb0186200", "module": "pbr_sphere", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T12:14:32Z", + "sealed_at": "2026-10-18T13:21:30Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:f3ca999dd227cac19751b0893e7c28bdff22990a115dedf1f039ca1752c2d1ad", + "spec_hash": "sha256:2d13f0e5ae9032c886b4d9e0d64446a2589a67198d60b35e20d2cdd0d7e1280c", "spec_path": "specs/t27f/pbr_sphere.t27", "tests": { "failed": 0, diff --git a/.trinity/seals/t27f_reel_marketing.json b/.trinity/seals/t27f_reel_marketing.json new file mode 100644 index 0000000000..b4d48b9908 --- /dev/null +++ b/.trinity/seals/t27f_reel_marketing.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:170aa9406c474daab02c87af57ac516f335774efd4d3d7bb89d96a84e8dfc457", + "gen_hash_rust": "sha256:92e914c0f4c16e0de23bf37af5600c32ec946e8555fee16dc7300f4162a93cf0", + "gen_hash_verilog": "sha256:010bff17265838d9f4530772874ded57be21acf2fd9fb5be39ee8face6c2c765", + "gen_hash_zig": "sha256:8741fa9583b4006f8510c672b644bdb7d9888ad1ec8d037d81e63751310cb7d5", + "module": "reel_marketing", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T13:21:28Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:ab07f6476d900cc0e089ba1d851f0b610a77b778f00fd906d1b98a29281941c7", + "spec_path": "specs/t27f/reel_marketing.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 3, + "total": 3, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/t27f_token_floor.json b/.trinity/seals/t27f_token_floor.json index 0ed69745c3..3767e7fa07 100644 --- a/.trinity/seals/t27f_token_floor.json +++ b/.trinity/seals/t27f_token_floor.json @@ -15,7 +15,7 @@ }, { "spec": "specs/t27f/pbr_sphere.t27", - "spec_hash": "sha256:f3ca999dd227cac19751b0893e7c28bdff22990a115dedf1f039ca1752c2d1ad" + "spec_hash": "sha256:2d13f0e5ae9032c886b4d9e0d64446a2589a67198d60b35e20d2cdd0d7e1280c" }, { "spec": "specs/t27f/reel_particles.t27", @@ -23,27 +23,27 @@ }, { "spec": "specs/t27f/token_floor_scenes.t27", - "spec_hash": "sha256:0cdfbe9ca10442fe1f29d54a75d433ea60dafb399e45caae678838bc660d552e" + "spec_hash": "sha256:948cff4bd34341b40b43c8a170e2a37e3bca59f8d6bc8e35d7e5ee43fe89a4ea" } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:d5aea31005f78eb0d5d5b37c8fd9eb374a4eaa40869c79c116308ad2cd133c51", - "gen_hash_rust": "sha256:6bafe70fc1a9eb0c8a08047d4898b6b0cf75dd3035c862ba9439e0016a34770f", - "gen_hash_verilog": "sha256:4b19d1b78744c6a770c82b778f42265f6d6c3650d8ec6eeabf690ea66153cdd5", - "gen_hash_zig": "sha256:872a9851daa393c95e1b290fcc6c7f85b602c5493f5ddccf4e265637bc7977c6", + "gen_hash_c": "sha256:0f358d8ff0757768f04d35ecbecd679b091c43d6d268fa8c28366fa31a3f9167", + "gen_hash_rust": "sha256:d263a762901fc286cc2d2d20c8f6445f93561933d8bdd043ba079dfcd2e686aa", + "gen_hash_verilog": "sha256:b495c59195d25d5eb3addaaf9da41cf69117a4d2c6fcf4e9320d95cbc3df0314", + "gen_hash_zig": "sha256:4c978f26d1d0a358bde62e9ef6221ec01a06d02aca24c36ada41b3c12d4b85f4", "module": "token_floor", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T12:16:20Z", + "sealed_at": "2026-10-18T13:21:32Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:af7ecb226097febe8c528424ccf5eb30fd30ef3d7350e485baac7cbd34521322", + "spec_hash": "sha256:82524e666fafe6098d85d86cfd93932e1e488f318e28ba0eff93c54cb656f8e8", "spec_path": "specs/t27f/token_floor.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 34, - "total": 34, + "passed": 35, + "total": 35, "vacuous": 0 }, "toolchain": { diff --git a/.trinity/seals/t27f_token_floor_mux.json b/.trinity/seals/t27f_token_floor_mux.json index 8ccce69d51..6c533d1ff5 100644 --- a/.trinity/seals/t27f_token_floor_mux.json +++ b/.trinity/seals/t27f_token_floor_mux.json @@ -9,37 +9,41 @@ "spec": "specs/t27f/particle_engine.t27", "spec_hash": "sha256:1b99d151382c41e99a4e928842ee0abcb984d2a0ee43abf5fbd602097d6abadd" }, + { + "spec": "specs/t27f/reel_marketing.t27", + "spec_hash": "sha256:ab07f6476d900cc0e089ba1d851f0b610a77b778f00fd906d1b98a29281941c7" + }, { "spec": "specs/t27f/reel_particles_mux.t27", "spec_hash": "sha256:1f5486ae5a6dabe9c059733f1f45d1646ca98b555e07103e00e2dc0be53f0747" }, { "spec": "specs/t27f/token_floor.t27", - "spec_hash": "sha256:84c176ce3324b3f924f5be142130ebb15f26a32e63cd93d7c7f1235b0022798b" + "spec_hash": "sha256:82524e666fafe6098d85d86cfd93932e1e488f318e28ba0eff93c54cb656f8e8" }, { "spec": "specs/t27f/token_floor_scenes.t27", - "spec_hash": "sha256:0cdfbe9ca10442fe1f29d54a75d433ea60dafb399e45caae678838bc660d552e" + "spec_hash": "sha256:948cff4bd34341b40b43c8a170e2a37e3bca59f8d6bc8e35d7e5ee43fe89a4ea" } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:11a39577b1ce474e322a0a17f1a6658c977fd607958b724025435ae59569f7f8", - "gen_hash_rust": "sha256:04208153e7b89c2bf88f43a5617126236ecb23bbb2e3ba2f8e1e3b5d79b8951c", - "gen_hash_verilog": "sha256:76dbeb916a6d231f5488baaf56bec937f8697aae56907e615bb916380c2797a2", - "gen_hash_zig": "sha256:3d88cdb8db2b955868ecce0e8d3921ce30e4e428b61569b0347d10767ea08d29", + "gen_hash_c": "sha256:18710ba70409c54ce28a6baa8da85579904bf6eeec5e91f4913496216a7fb9cb", + "gen_hash_rust": "sha256:c7240d2db316d11bbff444fed4725b84a77bd566a92e6286dcc4966ae9f2bf06", + "gen_hash_verilog": "sha256:7c6af1b05a4ecfb5560ff39e4728fd96b7ab95c825c428289ab330456bc44dd7", + "gen_hash_zig": "sha256:3c98bd93b0f615e0aba5a42e5891ab8ea4288ba98397bdb3f989e059853a9cd5", "module": "token_floor_mux", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T12:14:37Z", + "sealed_at": "2026-10-18T13:21:31Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:d36ea49889e010b155bec7b823f1103963bf2471bbe4646f3f56ef610474fd05", + "spec_hash": "sha256:6b83ab94e55a46aa538701fa568cb5493e1c6766ae008386916cf83335e5c7b1", "spec_path": "specs/t27f/token_floor_mux.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 14, - "total": 14, + "passed": 16, + "total": 16, "vacuous": 0 }, "toolchain": { diff --git a/.trinity/seals/t27f_token_floor_scenes.json b/.trinity/seals/t27f_token_floor_scenes.json index f75f84de91..440463672e 100644 --- a/.trinity/seals/t27f_token_floor_scenes.json +++ b/.trinity/seals/t27f_token_floor_scenes.json @@ -1,24 +1,33 @@ { "built_by": "t27c-bootstrap@0.5.2+9448ab4be", - "closure": [], + "closure": [ + { + "spec": "specs/t27f/pbr_sphere.t27", + "spec_hash": "sha256:2d13f0e5ae9032c886b4d9e0d64446a2589a67198d60b35e20d2cdd0d7e1280c" + }, + { + "spec": "specs/t27f/reel_marketing.t27", + "spec_hash": "sha256:ab07f6476d900cc0e089ba1d851f0b610a77b778f00fd906d1b98a29281941c7" + } + ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:0b13178692bc1a8cacc426ff975d81b3b1e6e8d228e1b045a00685f4139487f5", - "gen_hash_rust": "sha256:947642f7ab01247088201607ef4ed132c8b366766046d053dbcec84a57c48ecc", - "gen_hash_verilog": "sha256:f3ca1ecad2079d5b09b275d89920e6b688297ae3f3425fa701d580dca663c642", - "gen_hash_zig": "sha256:d4aa0250db25da21749e13d7127ce2c22b93d8f0cfa5daaa18eaddda4f032027", + "gen_hash_c": "sha256:03aab73cb0ef39f09d9561c785c39cbb1d189b7e51127f3c43534d173a6d95fd", + "gen_hash_rust": "sha256:41df46ffd2cf0a8cd25e17ab54788caf6b9f3d75530e2772b830d5236a99e0eb", + "gen_hash_verilog": "sha256:9bbd947a5548b2802f0a606a4936a4158c68a1b8f29fbb332c0b0b563a9aeca0", + "gen_hash_zig": "sha256:f032a09380f4e6160c479a54afeddca5462a4e21d0f8bf6cd0f106bdede9d873", "module": "token_floor_scenes", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T12:14:34Z", + "sealed_at": "2026-10-18T13:21:30Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:0cdfbe9ca10442fe1f29d54a75d433ea60dafb399e45caae678838bc660d552e", + "spec_hash": "sha256:948cff4bd34341b40b43c8a170e2a37e3bca59f8d6bc8e35d7e5ee43fe89a4ea", "spec_path": "specs/t27f/token_floor_scenes.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 11, - "total": 11, + "passed": 14, + "total": 14, "vacuous": 0 }, "toolchain": { diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 59f54f0a39..963e45a414 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -12,7 +12,7 @@ "reference-bug": "the reference passes it for the wrong reason; t27b is right not to", "n/a": "outside what t27b is for (say why in the PR that sets it)" }, - "max_not_pass": 74, + "max_not_pass": 75, "entries": [ {"path": "specs/a/b_c.t27", "t27b": "pass"}, {"path": "specs/a_b/c.t27", "t27b": "pass"}, @@ -1334,6 +1334,7 @@ {"path": "specs/t27f/build.t27", "t27b": "pass"}, {"path": "specs/t27f/deck.t27", "t27b": "pass"}, {"path": "specs/t27f/hot.t27", "t27b": "pass"}, + {"path": "specs/t27f/pbr_sphere.t27", "t27b": "pass"}, {"path": "specs/t27f/quest.t27", "t27b": "pass"}, {"path": "specs/t27f/reel.t27", "t27b": "pass"}, {"path": "specs/t27f/reel_gf16_nmse.t27", "t27b": "pass"}, @@ -1344,6 +1345,7 @@ {"path": "specs/t27f/spec_graph_fold.t27", "t27b": "pass"}, {"path": "specs/t27f/spec_graph_outline.t27", "t27b": "pass"}, {"path": "specs/t27f/spec_graph_view.t27", "t27b": "pass"}, + {"path": "specs/t27f/token_floor.t27", "t27b": "blocked", "blocker": "type [N]T", "reason": "unimplemented"}, {"path": "specs/t27f/topic_engine.t27", "t27b": "pass"}, {"path": "specs/t27f/topic_reel.t27", "t27b": "pass"}, {"path": "specs/t27f/ui_plan.t27", "t27b": "pass"}, diff --git a/specs/t27f/pbr_sphere.t27 b/specs/t27f/pbr_sphere.t27 index 675419ec11..bddff40b3c 100644 --- a/specs/t27f/pbr_sphere.t27 +++ b/specs/t27f/pbr_sphere.t27 @@ -83,7 +83,7 @@ pub const SHUTTER : f64 = 0.5; // 180 degrees: the shutter is open // ---- small helpers ---- -fn clamp01(x: f64) -> f64 { +pub fn clamp01(x: f64) -> f64 { if (x < 0.0) { return 0.0; } if (x > 1.0) { return 1.0; } return x; diff --git a/specs/t27f/reel_marketing.t27 b/specs/t27f/reel_marketing.t27 new file mode 100644 index 0000000000..e4b88e5215 --- /dev/null +++ b/specs/t27f/reel_marketing.t27 @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/reel_marketing.t27 -- what a short vertical reel must do to work as marketing, as +// numbers a renderer's layout and timing are tested against: where the platforms draw their own +// interface over the picture, how soon the hook must land, how often the picture must change, and +// how long and how big the call to action stays at the end. +// +// WHY: the owner, 2026-10-18, on the token_floor reel: study how marketing reels are made, so that +// ours is marketing too. A reel that is right but sits under the platform's buttons, opens on an +// empty frame or ends without an address does not sell anything. +// +// THE PRACTICE, as the 2026 creator and vendor guides give it (directional: none of it is a +// platform's published law, and the guides disagree on the numbers): +// +// the overlay Instagram Reels, TikTok and YouTube Shorts draw over a 1080x1920 reel: the +// account line at the top, the caption, the audio line and the action button at +// the bottom, the like/comment/share rail on the right. Measured margins, organic +// posts, by source, are in REEL_SOURCE_MARGIN; REEL_SAFE takes the widest margin of +// every edge, so what is inside shows on all three alike. Ads need more (Meta +// asks for the bottom 40% free under a disclaimer): NOT CLAIMED here, and that is +// the zone specs/automation/crm-lead-magnet.t27 holds for its story frame. +// the hook most of the viewers who leave, leave in the first 3 s. The statement is on +// screen, written and readable, inside REEL_HOOK_TEXT_MS, and the voice has +// started inside REEL_FIRST_WORD_MS. +// captions feeds autoplay muted, so the words are burned in, inside the safe zone, and +// the first caption is the hook itself. +// pace a new picture -- a new shot, the camera arriving somewhere -- at least every +// REEL_RESET_MS, or attention resets elsewhere. +// the CTA the address, big, inside the safe zone, held still for the reel's last +// REEL_CTA_MIN_MS at least. The house template holds 3 s +// (owner_voice_reel.CTA_HOLD_MS); a reel whose voice runs longer keeps this floor. +// the length 15 to 30 s keeps retention highest. +// +// Sources (fetched 2026-10-18): postplanify.com/blog/social-media-safe-zones-2026-complete-guide, +// uplads.com/tools/safe-zone-checker (zones verified July 2026), xyla.ai/tools/social-media-sizes, +// reelsafezone.com, adaptlypost.com/blog/social-media-safe-zones-2026-complete-guide, +// opus.pro/blog/ideal-youtube-shorts-length-format-retention, capcut.com/create/short-form-video-hooks. +// +// NOT CLAIMED: that a reel inside these numbers is watched; the numbers are the floor a marketing +// reel stands on, not a forecast of its reach. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module reel_marketing; + +pub const ID : str = "reel-marketing"; +pub const MEASURED_ON : str = "2026-10-18"; + +// ---- the overlay ---- +pub const REEL_W : f64 = 1080.0; +pub const REEL_H : f64 = 1920.0; + +// Per source, organic post, px of 1080x1920: top, bottom, left, right. 0 where the source names +// no margin on that edge. +// 0 TikTok, PostPlanify 1 TikTok, Uplads (organic) 2 TikTok, Xyla +// 3 Instagram Reels, PostPlanify 4 Instagram Reels, ReelSafeZone 5 Instagram Reels, Xyla +// 6 all three, AdaptlyPost: a 900x1400 area centred in the frame +pub const REEL_SOURCES : usize = 7; +pub const REEL_SOURCE_MARGIN : [28]f64 = [ + 108.0, 320.0, 60.0, 120.0, + 130.0, 320.0, 44.0, 140.0, + 130.0, 484.0, 0.0, 140.0, + 210.0, 310.0, 0.0, 84.0, + 220.0, 280.0, 0.0, 160.0, + 220.0, 430.0, 130.0, 130.0, + 260.0, 260.0, 90.0, 90.0 +]; + +// The widest margin of edge e (0 top, 1 bottom, 2 left, 3 right) over every source. +pub fn reel_margin(e: usize) -> f64 { + var m : f64 = 0.0; + var s : usize = 0; + while (s < REEL_SOURCES) { + const v = REEL_SOURCE_MARGIN[s * 4 + e]; + if (v > m) { m = v; } + s = s + 1; + } + return m; +} + +// The safe rect of the tall frame, k 0..3: x0, y0, x1, y1. +pub fn reel_safe(k: usize) -> f64 { + if (k == 0) { return reel_margin(2); } + if (k == 1) { return reel_margin(0); } + if (k == 2) { return REEL_W - reel_margin(3); } + return REEL_H - reel_margin(1); +} + +// True when the box (x0, y0)-(x1, y1), tall-frame px, shows on every platform. +pub fn reel_shows(x0: f64, y0: f64, x1: f64, y1: f64) -> bool { + return x0 >= reel_safe(0) && y0 >= reel_safe(1) && x1 <= reel_safe(2) && y1 <= reel_safe(3); +} + +// ---- the clock ---- +pub const REEL_FIRST_WORD_MS : i32 = 500; // the voice has started +pub const REEL_HOOK_TEXT_MS : i32 = 1000; // the hook's statement is written on screen +pub const REEL_HOOK_MS : i32 = 3000; // the window the viewer decides in +pub const REEL_RESET_MS : i32 = 3000; // the longest a picture may stay unchanged +pub const REEL_CTA_MIN_MS : i32 = 1500; // the address, still, at the end +pub const REEL_MIN_MS : i32 = 15000; +pub const REEL_MAX_MS : i32 = 30000; + +// ---- legibility ---- +// A 1080 px wide frame is about 68 mm on a 6.1 inch phone: 40 px of cap height is 2.5 mm, the +// smallest a statement is read at a glance; a CTA line is set at 64 px of type or more. +pub const REEL_TEXT_MIN_PX : f64 = 40.0; +pub const REEL_CTA_MIN_PX : f64 = 64.0; + +pub fn reel_length_ok(ms: i32) -> bool { + return ms >= REEL_MIN_MS && ms <= REEL_MAX_MS; +} + +// ---- tests ---- + +test "the safe rect takes the widest margin of every edge, and leaves a picture" { + assert(reel_margin(0) == 260.0); + assert(reel_margin(1) == 484.0); + assert(reel_margin(2) == 130.0); + assert(reel_margin(3) == 160.0); + // every source's own zone holds the union: no edge of it is closer than any source allows + var s : usize = 0; + while (s < REEL_SOURCES) { + assert(reel_safe(1) >= REEL_SOURCE_MARGIN[s * 4]); + assert(REEL_H - reel_safe(3) >= REEL_SOURCE_MARGIN[s * 4 + 1]); + assert(reel_safe(0) >= REEL_SOURCE_MARGIN[s * 4 + 2]); + assert(REEL_W - reel_safe(2) >= REEL_SOURCE_MARGIN[s * 4 + 3]); + s = s + 1; + } + // what is left is 790 x 1176, three fifths of the frame's height + assert(reel_safe(2) - reel_safe(0) == 790.0); + assert(reel_safe(3) - reel_safe(1) == 1176.0); +} + +test "the overlay check bites: the old caption line and the old tag line are under the platform" { + // a caption centred at y 1700, as token_floor's tall cut had it, is under the caption and audio + assert(reel_shows(60.0, 1629.0, 1020.0, 1777.0) == false); + // a tag at y 96 is under the account line; one at the right edge under the rail + assert(reel_shows(60.0, 96.0, 400.0, 132.0) == false); + assert(reel_shows(600.0, 600.0, 1000.0, 640.0) == false); + assert(reel_shows(130.0, 260.0, 920.0, 1436.0)); +} + +test "the clock: the hook inside the decision window, the CTA after it, the length in range" { + assert(REEL_FIRST_WORD_MS < REEL_HOOK_TEXT_MS); + assert(REEL_HOOK_TEXT_MS < REEL_HOOK_MS); + assert(REEL_HOOK_MS + REEL_CTA_MIN_MS < REEL_MIN_MS); + assert(REEL_RESET_MS * 5 <= REEL_MIN_MS); + assert(reel_length_ok(15000)); + assert(reel_length_ok(30000)); + assert(reel_length_ok(14999) == false); + assert(reel_length_ok(45000) == false); + assert(REEL_TEXT_MIN_PX < REEL_CTA_MIN_PX); +} diff --git a/specs/t27f/token_floor.t27 b/specs/t27f/token_floor.t27 index 315847ef2b..9470906951 100644 --- a/specs/t27f/token_floor.t27 +++ b/specs/t27f/token_floor.t27 @@ -42,12 +42,14 @@ // THE FILM (specs/t27f/token_floor_scenes.t27 and specs/t27f/pbr_sphere.t27): the owner, // 2026-10-18, asked for real-looking balls, a story told in shots instead of one still frame, and // a horizontal and a vertical cut of the same reel. The stage above is built and run in world px -// exactly as before; each frame the camera of its shot (eight shots: cuts on the beats the voice -// names, two fast moves and a closing dolly) maps it to the screen. Every moving splat is swept -// over the half frame a 180-degree shutter is open, so a falling ball and a flying letter smear as -// a camera sees them, and a cut carries no smear across it. A ball is shaded by pbr_sphere: GGX -// gold or glazed ceramic under four softboxes, with the ball above and the one below reflected in -// it and shadowing it. A landing flashes at the contact; a word's dots start left to right, one +// exactly as before; each frame the camera of its shot maps it to the screen: eight shots, and +// between them no cut but a drone's flight, one smooth path that pulls back on a long way, as the +// owner asked after the first cut ("too jerky; fly like a drone"). Every moving splat is swept +// over the half frame a 180-degree shutter is open, so a falling ball, a flying letter and the +// flying camera smear as a camera sees them. A label the camera carries past the safe rect fades +// out whole (edge_fit), so no line is left half under the card or cut by the frame. A ball is +// shaded by pbr_sphere: GGX gold or glazed ceramic under four softboxes, with the ball above and +// the one below reflected in it and shadowing it. A landing flashes at the contact; a word's dots start left to right, one // frame each STAGGER_PX of its width; a glass card with a gold edge and a PASS pill rises over the // three verdict beats; the lens adds a vignette and half a code value of dither before rounding. // @@ -118,6 +120,7 @@ use t27f::token_floor_scenes::frame_w; use t27f::token_floor_scenes::frame_h; use t27f::token_floor_scenes::shot_of; use t27f::token_floor_scenes::pose; +use t27f::token_floor_scenes::smoother; use t27f::token_floor_scenes::overshoot; use t27f::token_floor_scenes::safe_f; use t27f::token_floor_scenes::card_of; @@ -130,6 +133,7 @@ use t27f::token_floor_scenes::PILL_W; use t27f::token_floor_scenes::PILL_H; use t27f::pbr_sphere::shade; use t27f::pbr_sphere::swept_cover; +use t27f::pbr_sphere::clamp01; use t27f::pbr_sphere::swept_mid; use t27f::pbr_sphere::SHUTTER; use t27f::pbr_sphere::NO_NB; @@ -687,12 +691,20 @@ fn pd_closed(t: f64, x0: f64, g: f64) -> f64 { return x0 * (1.0 + w * t) * @exp(0.0 - w * t); } -// ---- light ---- +// ---- the edge fade: a label the camera carries out of the scene's safe rect goes, whole ---- +// The owner, 2026-10-18: the text runs over itself and does not look right. A label the camera +// leaves half under the card, or cut by the frame's edge, is read as a broken line; it fades out +// as one piece over its first EDGE_FADE px past the rect, and back in when the camera returns. +pub const EDGE_FADE : f64 = 16.0; -fn clamp01(x: f64) -> f64 { - if (x < 0.0) { return 0.0; } - if (x > 1.0) { return 1.0; } - return x; +// The opacity of a screen box (x0, y0)-(x1, y1) in cut o: 1 inside the safe rect, 0 from +// EDGE_FADE px past any of its edges on. +pub fn edge_fit(o: usize, x0: f64, y0: f64, x1: f64, y1: f64) -> f64 { + var over : f64 = safe_f(o, 0) - x0; + if (safe_f(o, 1) - y0 > over) { over = safe_f(o, 1) - y0; } + if (x1 - safe_f(o, 2) > over) { over = x1 - safe_f(o, 2); } + if (y1 - safe_f(o, 3) > over) { over = y1 - safe_f(o, 3); } + return 1.0 - smoother(clamp01(over / EDGE_FADE)); } fn key_w(d: f64) -> f64 { @@ -1070,6 +1082,11 @@ fn render(o: usize, f_lo: i32, f_hi: i32) -> void { var nd : usize = 0; var lfirst : [53]usize = undefined; var lnum : [53]usize = undefined; + // each label's box in the world, from the places of its dots: where the edge fade looks + var lbx0 : [53]f64 = undefined; + var lby0 : [53]f64 = undefined; + var lbx1 : [53]f64 = undefined; + var lby1 : [53]f64 = undefined; l = 0; while (l < LABELS) { lfirst[l] = nd; @@ -1114,6 +1131,19 @@ fn render(o: usize, f_lo: i32, f_hi: i32) -> void { c = c + 1; } lnum[l] = nd - lfirst[l]; + const ldot = DOT_R * grid(lbl_f(l, 2)); + lbx0[l] = 1.0e9; + lby0[l] = 1.0e9; + lbx1[l] = 0.0 - 1.0e9; + lby1[l] = 0.0 - 1.0e9; + var dd : usize = lfirst[l]; + while (dd < nd) { + if (dtx[dd] - ldot < lbx0[l]) { lbx0[l] = dtx[dd] - ldot; } + if (dty[dd] - ldot < lby0[l]) { lby0[l] = dty[dd] - ldot; } + if (dtx[dd] + ldot > lbx1[l]) { lbx1[l] = dtx[dd] + ldot; } + if (dty[dd] + ldot > lby1[l]) { lby1[l] = dty[dd] + ldot; } + dd = dd + 1; + } l = l + 1; } // the counter's exponent is one pool of particles that re-forms into each next number; the @@ -1297,6 +1327,24 @@ fn render(o: usize, f_lo: i32, f_hi: i32) -> void { frame = frame + 1; continue; } + // the camera of this frame and of the last: a flight is one path across shots, so the + // last frame is seen through its own shot's camera and the blur follows the flight + const s = shot_of(frame); + const sp = shot_of(frame - 1); + const ff = @as(f64, @floatFromInt(frame)); + const zc = pose(o, s, ff, 2); + const cxc = pose(o, s, ff, 0); + const cyc = pose(o, s, ff, 1); + const zp = pose(o, sp, ff - 1.0, 2); + const cxp = pose(o, sp, ff - 1.0, 0); + const cyp = pose(o, sp, ff - 1.0, 1); + // each label's opacity at the safe rect's edges, through this frame's camera + var fitl : [53]f64 = undefined; + l = 0; + while (l < LABELS) { + fitl[l] = edge_fit(o, (lbx0[l] - cxc) * zc + hw2, (lby0[l] - cyc) * zc + hh2, (lbx1[l] - cxc) * zc + hw2, (lby1[l] - cyc) * zc + hh2); + l = l + 1; + } var n : usize = 0; // the tube's image in the floor var w : usize = 0; @@ -1320,13 +1368,13 @@ fn render(o: usize, f_lo: i32, f_hi: i32) -> void { const gl = glow_of(dvx[d] / PXM, dvy[d] / PXM) * dw[d]; const ox = dx[d] - dvx[d] / fps; const oy = dy[d] - dvy[d] / fps; - sx[n] = dx[d]; sy[n] = dy[d]; sr[n] = DOT_R * q; sw[n] = 0.0; sa[n] = a * gl; + sx[n] = dx[d]; sy[n] = dy[d]; sr[n] = DOT_R * q; sw[n] = 0.0; sa[n] = a * gl * fitl[ld]; scr[n] = COL[ci]; scg[n] = COL[ci + 1]; scb[n] = COL[ci + 2]; sk[n] = K_DOT; sm[n] = 0; spx[n] = ox; spy[n] = oy; sb[n] = 0; n = n + 1; if (dy[d] < FLOOR_PX && FLOOR_PX - dy[d] < REFLECT_SPAN) { sx[n] = dx[d]; sy[n] = 2.0 * FLOOR_PX - dy[d]; sr[n] = DOT_R * q; sw[n] = 0.0; - sa[n] = a * gl * reflect_alpha(dy[d]); + sa[n] = a * gl * reflect_alpha(dy[d]) * fitl[ld]; scr[n] = COL[ci]; scg[n] = COL[ci + 1]; scb[n] = COL[ci + 2]; sk[n] = K_DOT; sm[n] = 1; spx[n] = ox; spy[n] = 2.0 * FLOOR_PX - oy; sb[n] = 0; n = n + 1; @@ -1340,7 +1388,7 @@ fn render(o: usize, f_lo: i32, f_hi: i32) -> void { while (pi < POOL) { if (cborn[pi] && ca[pi] > 0.0) { sx[n] = cx[pi]; sy[n] = cy[pi]; sr[n] = DOT_R * grid(EXP_PITCH); sw[n] = 0.0; - sa[n] = ca[pi] * cw[pi] * glow_of(cvx[pi] / PXM, cvy[pi] / PXM); + sa[n] = ca[pi] * cw[pi] * glow_of(cvx[pi] / PXM, cvy[pi] / PXM) * edge_fit(o, (cx[pi] - cxc) * zc + hw2, (cy[pi] - cyc) * zc + hh2, (cx[pi] - cxc) * zc + hw2, (cy[pi] - cyc) * zc + hh2); scr[n] = COL[0]; scg[n] = COL[1]; scb[n] = COL[2]; sk[n] = K_DOT; sm[n] = 0; spx[n] = cx[pi] - cvx[pi] / fps; spy[n] = cy[pi] - cvy[pi] / fps; sb[n] = 0; n = n + 1; @@ -1393,15 +1441,6 @@ fn render(o: usize, f_lo: i32, f_hi: i32) -> void { spx[n] = sx[n]; spy[n] = sy[n]; sb[n] = 0; n = n + 1; // ---- the camera: world to screen, each splat swept over the open shutter ---- - // A frame's previous position is seen through the same shot's camera, so a cut has no blur. - const s = shot_of(frame); - const ff = @as(f64, @floatFromInt(frame)); - const zc = pose(o, s, ff, 2); - const cxc = pose(o, s, ff, 0); - const cyc = pose(o, s, ff, 1); - const zp = pose(o, s, ff - 1.0, 2); - const cxp = pose(o, s, ff - 1.0, 0); - const cyp = pose(o, s, ff - 1.0, 1); var m : usize = 0; var j : usize = 0; while (j < n) { @@ -2070,7 +2109,7 @@ fn shot_subject(s: usize, k: usize) -> f64 { } // The worst overshoot of shot s's subject past orientation o's safe rect, screen px: the closing -// dolly from frame 430, the pan up the column not at all. +// push-in from frame 430, the pan up the column not at all. fn shot_over(o: usize, s: usize) -> f64 { if (s == 4) { return 0.0; } var from : f64 = 0.0; @@ -2113,6 +2152,31 @@ test "every shot frames what it is about, in both frames" { assert(overshoot(TALL, 1, 0.0, shot_subject(0, 0), shot_subject(0, 1), shot_subject(0, 2), shot_subject(0, 3)) > 100.0); } +test "a label past the safe rect fades whole: full inside, gone from EDGE_FADE px out" { + var o : usize = 0; + while (o < 2) { + const x0 = safe_f(o, 0); + const y0 = safe_f(o, 1); + const x1 = safe_f(o, 2); + const y1 = safe_f(o, 3); + // the safe rect itself, and a box inside it + assert(edge_fit(o, x0, y0, x1, y1) == 1.0); + assert(edge_fit(o, x0 + 50.0, y0 + 50.0, x0 + 200.0, y0 + 90.0) == 1.0); + // half way through the fade on every edge, and gone past it + assert(@abs(edge_fit(o, x0 - 0.5 * EDGE_FADE, y0 + 50.0, x0 + 200.0, y0 + 90.0) - 0.5) < 1e-12); + assert(@abs(edge_fit(o, x0 + 50.0, y0 - 0.5 * EDGE_FADE, x0 + 200.0, y0 + 90.0) - 0.5) < 1e-12); + assert(@abs(edge_fit(o, x1 - 200.0, y0 + 50.0, x1 + 0.5 * EDGE_FADE, y0 + 90.0) - 0.5) < 1e-12); + assert(@abs(edge_fit(o, x0 + 50.0, y1 - 90.0, x0 + 200.0, y1 + 0.5 * EDGE_FADE) - 0.5) < 1e-12); + assert(edge_fit(o, x0 + 50.0, y1 - 20.0, x0 + 200.0, y1 + EDGE_FADE) == 0.0); + // the worst edge decides: a box a little out on one side and far out on another is gone + assert(edge_fit(o, x0 - 2.0, y0 + 50.0, x1 + 40.0, y0 + 90.0) == 0.0); + o = o + 1; + } + // the tall card's top is inside the fade's reach of the scene's foot: a label that reaches the + // card is already gone + assert(card_f(TALL, 1) - safe_f(TALL, 3) <= EDGE_FADE); +} + test "the row index lists every row a splat touches, and no more than span_max rows" { // a dot, a halo, a ball and a vertical line, each at 300 centres from above the frame to below // it, a third of a pixel apart, then as mirror images diff --git a/specs/t27f/token_floor_mux.t27 b/specs/t27f/token_floor_mux.t27 index dccf57a4f2..a5c6f80852 100644 --- a/specs/t27f/token_floor_mux.t27 +++ b/specs/t27f/token_floor_mux.t27 @@ -31,14 +31,19 @@ // with the reel's own tone curve: the gold is particle_engine's expose of token_floor's gold ball // light, so a caption is the colour of the balls it names, not a yellow picked by hand. Two // levels, as in tetsuo's interfaces: the captions are big and gold; the scene tags and the card's -// path line are small and grey, with the tag's number in gold. A tag slides in from the left at -// each shot; the card's lines rise with the glass card the frames draw, on the same critically -// damped path and at the same opacity. +// path line are small and grey, with the tag's number in gold. A tag slides in from the right at +// each shot, onto a place inside the platforms' safe zone; the card's lines rise with the glass +// card the frames draw, on the same critically damped path and at the same opacity. // // THE CAPTIONS sit in the band token_floor_scenes leaves for them: in the tall cut under the card, // in the wide one at the foot of the interface column right of the scene. They are left-aligned // at band_x and sized to fit band_w with their border. // +// THE MARKETING floor is specs/t27f/reel_marketing.t27, tested here, not restated: the voice and +// the first caption inside 0.5 s (the first caption is the hook: feeds play muted), every word at +// 40 px or more, every flight under way when its word comes, and after the last word the address +// t27.ai, centred in the caption band at the caption size, held to the end. +// // THE OUTPUT: `main` writes two graphs to stderr, one line each, the tall cut's and then the wide // cut's, for `ffmpeg -filter_complex_script`. Inputs: 0 = the frames, 1..6 = the phrases // p0.wav..p5.wav, 7 = the music (bgmusic.mp3). @@ -74,8 +79,7 @@ use t27f::token_floor_scenes::TALL; use t27f::token_floor_scenes::WIDE; use t27f::token_floor_scenes::SHOTS; use t27f::token_floor_scenes::SHOT_START; -use t27f::token_floor_scenes::SHOT_KIND; -use t27f::token_floor_scenes::CUT; +use t27f::token_floor_scenes::FLIGHT_F; use t27f::token_floor_scenes::TAG_X; use t27f::token_floor_scenes::TAG_Y; use t27f::token_floor_scenes::TAG_FONT; @@ -100,6 +104,14 @@ use t27f::token_floor_scenes::band_w; use t27f::token_floor_scenes::caption_y; use t27f::token_floor_scenes::frame_w; use t27f::token_floor_scenes::frame_h; +use t27f::reel_marketing::reel_shows; +use t27f::reel_marketing::reel_length_ok; +use t27f::reel_marketing::REEL_FIRST_WORD_MS; +use t27f::reel_marketing::REEL_HOOK_TEXT_MS; +use t27f::reel_marketing::REEL_HOOK_MS; +use t27f::reel_marketing::REEL_CTA_MIN_MS; +use t27f::reel_marketing::REEL_CTA_MIN_PX; +use t27f::reel_marketing::REEL_TEXT_MIN_PX; pub const ID : str = "token-floor-mux"; pub const WORD_DIR : str = "/root/tf/w"; @@ -234,12 +246,34 @@ fn rects_meet(ax0: f64, ay0: f64, ax1: f64, ay1: f64, bx0: f64, by0: f64, bx1: f return ax0 < bx1 && bx0 < ax1 && ay0 < by1 && by0 < ay1; } -// ---- the beats: a hard cut comes just before a word or an event, so the eye arrives first ---- -pub const BEAT_LEAD_MS : i32 = 450; +// ---- the call to action: the address, after the last word, held to the end ---- +pub const TF_CTA_TEXT : str = "t27.ai"; +pub const TF_CTA_CHARS : i32 = 6; +pub const TF_CTA_FADE_MS : i32 = 250; + +fn cta_from() -> i32 { + return word_until(WORDS - 1); +} + +fn cta_font(o: usize) -> i32 { + return band_font(o, TF_CTA_CHARS); +} + +// Centred in the caption band, where the words were: the eye is already there. +fn print_cta(o: usize) -> void { + const a = cta_from(); + std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=", .{ CAPTION_FONT_LAT, TF_CTA_TEXT, cta_font(o) }); + 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), caption_y(o), secs(a), millis(a), millis(TF_CTA_FADE_MS), secs(a), millis(a) }); +} + +// ---- the beats: a flight is under way when a word or an event comes, so the camera arrives +// with the voice and not after it ---- -// From ms to the next word start or event, or 100000 when there is none. +// From ms to the next word start, event or the CTA, or 100000 when there is none. fn beat_gap(ms: i32) -> i32 { var g : i32 = 100000; + if (cta_from() >= ms) { g = cta_from() - ms; } var k : usize = 0; while (k < WORDS) { const d = word_from(k) - ms; @@ -269,7 +303,7 @@ fn print_tag(o: usize, s: usize, number: bool) -> void { std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=", .{ CAPTION_FONT_LAT, tag_text(s), TAG_FONT[o] }); print_col(GREY_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], 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)':enable='between(n,{d},{d})'", .{ TAG_X[o], TAG_SLIDE_PX, a, TAG_SLIDE_F, TAG_Y[o], a, TAG_SLIDE_F, a, b }); } // ---- the cards' text: three lines and PASS, on the glass the frames draw ---- @@ -349,6 +383,7 @@ fn graph(o: usize) -> void { 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) }); k = k + 1; } + print_cta(o); std.debug.print("[v];", .{}); var p : usize = 0; while (p < PHRASES) { @@ -472,7 +507,7 @@ test "the captions keep to their band: in the frame, off the scene, the card and const cy0 = card_f(o, 1); assert(rects_meet(x0, y0, x1, y1, cx0, cy0, cx0 + card_f(o, 2), cy0 + card_f(o, 3) + CARD_RISE) == false); // the tag line, from where it slides in - const tx0 = @as(f64, @floatFromInt(TAG_X[o] - TAG_SLIDE_PX)); + const tx0 = @as(f64, @floatFromInt(TAG_X[o])); const ty0 = @as(f64, @floatFromInt(TAG_Y[o])); const ty1 = ty0 + 1.2 * @as(f64, @floatFromInt(TAG_FONT[o])); assert(rects_meet(x0, y0, x1, y1, tx0, ty0, w, ty1) == false); @@ -487,21 +522,31 @@ test "the box check bites: overlapping boxes meet, apart ones do not" { assert(rects_meet(caption_box(TALL, 0), caption_box(TALL, 1) - 300.0, caption_box(TALL, 2), caption_box(TALL, 3) - 300.0, card_f(TALL, 0), card_f(TALL, 1), card_f(TALL, 0) + card_f(TALL, 2), card_f(TALL, 1) + card_f(TALL, 3))); } -test "the tags sit above the scene, fit the frame, and slide in from inside it" { +test "the tags sit above the scene, fit the frame, and slide in from the right onto their place" { var o : usize = 0; while (o < 2) { + const tx = @as(f64, @floatFromInt(TAG_X[o])); + const ty0 = @as(f64, @floatFromInt(TAG_Y[o])); + const ty1 = ty0 + 1.2 * @as(f64, @floatFromInt(TAG_FONT[o])); var s : usize = 0; while (s < SHOTS) { const wd = @as(f64, @floatFromInt(tag_text(s).len)) * 0.6 * @as(f64, @floatFromInt(TAG_FONT[o])); - assert(@as(f64, @floatFromInt(TAG_X[o])) + wd + 40.0 <= @as(f64, @floatFromInt(frame_w(o)))); + // the slide starts TAG_SLIDE_PX to the right of the tag's place + const tx1 = tx + @as(f64, @floatFromInt(TAG_SLIDE_PX)) + wd; + assert(tx1 + 40.0 <= @as(f64, @floatFromInt(frame_w(o)))); + if (o == TALL) { + // reel_marketing: the whole slide shows on every platform + assert(reel_shows(tx, ty0, tx1, ty1)); + } s = s + 1; } - assert(TAG_X[o] - TAG_SLIDE_PX >= 20); - assert(@as(f64, @floatFromInt(TAG_Y[o])) + 1.2 * @as(f64, @floatFromInt(TAG_FONT[o])) <= safe_f(o, 1)); + assert(TAG_X[o] >= 20); + assert(ty1 <= safe_f(o, 1)); o = o + 1; } + // the bite: the tall tag where it was, at y 96, is under the account line + assert(reel_shows(@as(f64, @floatFromInt(TAG_X[TALL])), 96.0, 400.0, 132.0) == false); } - test "the card's text fits its card, and PASS sits in its pill right of the check" { var o : usize = 0; while (o < 2) { @@ -522,7 +567,7 @@ test "the card's text fits its card, and PASS sits in its pill right of the chec assert(card_font(o, 0) < card_font(o, 1)); o = o + 1; } - assert(card_font(TALL, 0) == 26 && card_font(WIDE, 0) == 20); + assert(card_font(TALL, 0) == 25 && card_font(WIDE, 0) == 20); } test "the type is the reel's own gold, grey and white" { @@ -534,19 +579,53 @@ test "the type is the reel's own gold, grey and white" { assert(tone8(3 * GREY_ROW) > 120); } -test "every hard cut lands just before a word or an event" { - var n : i32 = 0; +test "every flight is under way when its word, event or the CTA comes" { var s : usize = 1; while (s < SHOTS) { - if (SHOT_KIND[s] == CUT) { - const g = beat_gap(frame_ms(SHOT_START[s])); - assert(g >= 0 && g <= BEAT_LEAD_MS); - n = n + 1; - } + const g = beat_gap(frame_ms(SHOT_START[s])); + std.debug.print("shot {d} beat in {d} ms of a {d} ms flight\n", .{ s, g, frame_ms(FLIGHT_F[s]) }); + assert(g >= 0 && g <= frame_ms(FLIGHT_F[s])); s = s + 1; } - assert(n == 4); - // the check bites: after the last word there is no beat, and a cut 200 ms late misses its word - assert(beat_gap(14000) > BEAT_LEAD_MS); - assert(beat_gap(frame_ms(SHOT_START[3]) + 200) > BEAT_LEAD_MS); + // the check bites: after the CTA there is no beat, and the last shot 900 ms late misses it + assert(beat_gap(14000) == 100000); + assert(beat_gap(frame_ms(SHOT_START[SHOTS - 1]) + 900) > frame_ms(FLIGHT_F[SHOTS - 1])); +} + +test "the hook: the voice inside 0.5 s, the first caption inside 1 s, the first phrase inside 3 s" { + // reel_marketing: feeds play muted, so the first caption is the hook and must come at once + assert(word_from(0) <= REEL_FIRST_WORD_MS); + assert(phrase_start(0) <= REEL_FIRST_WORD_MS); + assert(word_from(0) <= REEL_HOOK_TEXT_MS); + assert(phrase_start(0) + PHRASE_MS[0] <= REEL_HOOK_MS); + var o : usize = 0; + while (o < 2) { + var k : usize = 0; + while (k < WORDS) { + assert(@as(f64, @floatFromInt(band_font(o, WORD_CHARS[k]))) >= REEL_TEXT_MIN_PX); + k = k + 1; + } + o = o + 1; + } + // the bite: a reel that waited for the white ball's landing to speak would open silent + assert(event_ms(1) > REEL_HOOK_MS - 1000); +} + +test "the CTA: the address after the last word, big, centred in the band, held to the end" { + assert(reel_length_ok(REEL_MS)); + assert(cta_from() == 13396); + assert(REEL_MS - cta_from() >= REEL_CTA_MIN_MS); + assert(cta_from() >= word_until(WORDS - 1)); + var o : usize = 0; + while (o < 2) { + assert(@as(f64, @floatFromInt(cta_font(o))) >= REEL_CTA_MIN_PX); + // centred in the band with its border, it fits it + assert(TF_CTA_CHARS * MONO_ADVANCE_PERMILLE * cta_font(o) + 2 * CAPTION_BORDER_PX * 1000 <= band_w(o) * 1000); + o = o + 1; + } + assert(TF_CTA_TEXT.len == 6); + // one line at a time: the CTA comes after the last caption, never over it, and a CTA that + // waited for the music to fade would be up for less than the floor + assert(word_from(WORDS - 1) < cta_from()); + assert(REEL_MS - MUSIC_FADE_OUT_START_MS < REEL_CTA_MIN_MS); } diff --git a/specs/t27f/token_floor_scenes.t27 b/specs/t27f/token_floor_scenes.t27 index 1942bfec19..ea586b54cf 100644 --- a/specs/t27f/token_floor_scenes.t27 +++ b/specs/t27f/token_floor_scenes.t27 @@ -1,37 +1,52 @@ // SPDX-License-Identifier: Apache-2.0 // specs/t27f/token_floor_scenes.t27 -- the token_floor reel cut into a story of shots: a camera per -// shot, the transitions between them, the glass spec cards that float over them, and the small -// mono scene tags, for the tall 1080x1920 frame and the wide 1920x1080 one. +// shot, the flights between them, the glass spec cards that float over them, and the small mono +// scene tags, for the tall 1080x1920 frame and the wide 1920x1080 one. // // WHY: the owner, 2026-10-18: one scene is boring; the transitions between shots must tell a story // of scenes. And: study the artist tetsuo (x.com/tetsuoai), who works in our direction, AI agents -// and AI interfaces made visible. What his reels do, and what this module does: +// and AI interfaces made visible. Then, on the first cut: the transitions are too jerky -- the +// camera should fly like a drone with a camera on it, as in The Matrix; and the text runs into +// itself. What his reels do, and what this module does: // -// shots 2-3 s each, cut on the beats of the voice, never on a clock. Here every hard -// cut falls just before a narration event or a phrase, so the eye arrives first -// and the word lands on what it already sees. -// one hero a recurring object carries the continuity across cuts. Here it is the column: -// every shot looks at the same world, only the camera moves. -// moves a slow push inside a shot, and a fast move between two shots when the story -// travels: the camera follows the white ball down, then the dots it sheds left. +// shots 2-3 s each, on the beats of the voice, never on a clock. Here every shot's +// flight is still in the air when its word or event lands, so the eye travels +// with the voice and arrives on what the word names. +// one hero a recurring object carries the continuity. Here it is the column: every shot +// looks at the same world, and the camera never leaves it -- there are no cuts. +// flights a slow push inside a shot, and between two shots a flight: the camera leaves at +// the speed it had, pulls back as a drone climbs when the way is long, and settles +// onto the next shot's push at that shot's speed. Nothing jumps, nothing jerks. // two levels a small mono label over a big bold statement. The statements are the reel's own // particle letters; the small labels are the scene tags, top left. // UI cards a translucent card in screen space, over the scene, showing the machine's side // of the story: here the t27 function that computes the number on screen, and a -// green PASS. A card stays up across a cut: the interface outlives the shot. +// green PASS. A card stays up across a flight: the interface outlives the shot. // // THE CAMERA maps the stage of specs/t27f/token_floor.t27 (world px, 1 px = 1 mm, the 1080x1920 // layout) to the screen: screen = (world - c) z + (W / 2, H / 2). It is axis-aligned, so the // stage's separable light stays separable. Inside a shot c moves linearly and z geometrically -// from pose 0 to pose 1. A shot that enters by a move (MOVE or DOLLY) first travels from the last -// pose of the shot before, on a smootherstep, over MOVE_F or DOLLY_F frames. +// from pose 0 to pose 1: the drift. A shot s > 0 opens with a flight of FLIGHT_F[s] frames from +// the drift of shot s - 1 to its own: a quintic Hermite in ln z, and one in the screen px the +// centre sweeps (c moves at its speed over z), each matching position and speed of the drifts at +// both ends, with a pull-back in ln z of FLIGHT_DIP ln(1 + d / D) at its middle for a flight of d +// screen px across a frame of diagonal D. Over all of it the drone hovers: slow sines of HOVER_PX +// on screen and HOVER_Z in zoom. // -// NOT CLAIMED: the shot list, the framings, the card layout and the tags are editing choices. -// This module owns where the camera looks and when; what is there to see is token_floor's. +// THE LAYOUT keeps the tall frame's text out from under the platforms' own interface: the tags, +// the scene, the card and the captions all lie inside reel_marketing's safe rect. +// +// NOT CLAIMED: the shot list, the framings, the flights, the card layout and the tags are editing +// choices. This module owns where the camera looks and when; what is there to see is token_floor's. // phi^2 + 1/phi^2 = 3 | TRINITY module token_floor_scenes; +use t27f::reel_marketing::reel_safe; +use t27f::reel_marketing::reel_shows; +use t27f::reel_marketing::REEL_RESET_MS; +use t27f::pbr_sphere::clamp01; + pub const ID : str = "token-floor-scenes"; pub const SCENE_FRAMES : i32 = 450; // token_floor's FRAMES; a test there holds them equal pub const SCENE_FPS : i32 = 30; @@ -52,29 +67,29 @@ pub fn frame_h(o: usize) -> i32 { // ---- the shots ---- pub const SHOTS : usize = 8; -pub const CUT : i64 = 0; -pub const MOVE : i64 = 1; // a fast camera move from the last shot's pose -pub const DOLLY : i64 = 2; // a slow one -pub const MOVE_F : f64 = 10.0; -pub const DOLLY_F : f64 = 24.0; // The first frame of each shot, and the reel's end. -pub const SHOT_START : [9]i32 = [0, 68, 88, 130, 200, 262, 326, 380, 450]; -// How each shot enters. -pub const SHOT_KIND : [8]i64 = [0, 1, 1, 0, 0, 0, 0, 2]; +pub const SHOT_START : [9]i32 = [0, 44, 84, 114, 180, 246, 302, 376, 450]; +// The frames each shot's flight takes, from where the shot before was looking to this shot's own +// drift. The first shot opens on its own pose; every other flight but the short hop from the ball +// to its labels takes a second, and starts before the voice names the shot it flies to. +pub const FLIGHT_F : [8]i32 = [0, 36, 22, 30, 30, 30, 30, 30]; +pub const FLIGHT_DIP : f64 = 0.8; // the pull-back, of ln(1 + way / diagonal) +pub const HOVER_PX : f64 = 3.0; // the drone's hover, screen px +pub const HOVER_Z : f64 = 0.004; // and its breathing in zoom // 0 the title, 1 the white ball strikes, 2 the floor's labels, 3 the counter, 4 up the column, // 5 the bill at the top, 6 the verdict, 7 the whole infographic. // Per frame and shot: pose 0 (cx, cy, z), then pose 1, world px and zoom. pub const CAM : [96]f64 = [ // tall - 340.0, 145.0, 1.55, 340.0, 150.0, 1.75, - 860.0, 1590.0, 3.2, 860.0, 1600.0, 3.5, - 308.5, 1658.5, 1.730, 308.5, 1647.2, 1.891, - 227.2, 828.2, 2.572, 227.2, 820.6, 2.812, - 880.0, 1300.0, 2.0, 880.0, 560.0, 2.0, - 355.6, 771.9, 1.455, 355.6, 758.4, 1.590, - 306.9, 1165.2, 1.742, 306.9, 1154.0, 1.904, - 534.0, 1305.0, 0.72, 534.0, 1410.4, 0.542, + 334.2, 360.6, 1.293, 333.2, 342.2, 1.414, + 864.7, 1713.2, 3.2, 864.3, 1705.7, 3.5, + 319.5, 1730.4, 1.367, 318.6, 1713.0, 1.494, + 234.5, 876.6, 2.032, 233.9, 864.9, 2.221, + 880.0, 1440.0, 2.0, 880.0, 700.0, 2.0, + 371.4, 908.9, 0.949, 370.1, 883.8, 1.037, + 317.8, 1236.7, 1.376, 316.9, 1219.3, 1.504, + 584.0, 1919.0, 0.300, 579.9, 1842.4, 0.327, // wide 528.1, 119.1, 1.801, 510.6, 121.3, 1.969, 860.0, 1590.0, 2.6, 860.0, 1600.0, 2.9, @@ -98,14 +113,12 @@ pub fn shot_len(s: usize) -> i32 { return SHOT_START[s + 1] - SHOT_START[s]; } -fn cam_f(o: usize, s: usize, k: usize) -> f64 { - return CAM[o * 48 + s * 6 + k]; +pub fn flight_f(s: usize) -> f64 { + return @as(f64, @floatFromInt(FLIGHT_F[s])); } -fn clamp01(x: f64) -> f64 { - if (x < 0.0) { return 0.0; } - if (x > 1.0) { return 1.0; } - return x; +fn cam_f(o: usize, s: usize, k: usize) -> f64 { + return CAM[o * 48 + s * 6 + k]; } // 6 t^5 - 15 t^4 + 10 t^3: starts and stops with no speed and no acceleration. @@ -125,34 +138,141 @@ fn drift(o: usize, s: usize, f: f64, k: usize) -> f64 { return a + (b - a) * u; } -fn entry_frames(s: usize) -> f64 { - if (SHOT_KIND[s] == MOVE) { return MOVE_F; } - if (SHOT_KIND[s] == DOLLY) { return DOLLY_F; } - return 0.0; +// The drift in the coordinate a flight is drawn in, c or ln z, and its rate per frame there. +fn drift_log(o: usize, s: usize, f: f64, k: usize) -> f64 { + if (k == 2) { return @log(drift(o, s, f, 2)); } + return drift(o, s, f, k); +} + +fn drift_rate(o: usize, s: usize, k: usize) -> f64 { + const n = @as(f64, @floatFromInt(shot_len(s))); + if (k == 2) { return @log(cam_f(o, s, 5) / cam_f(o, s, 2)) / n; } + return (cam_f(o, s, k + 3) - cam_f(o, s, k)) / n; +} + +// The quintic Hermite from p0 at speed v0 to p1 at speed v1 (speeds per unit of t), with no +// acceleration at either end. +fn cam_hermite(p0: f64, v0: f64, p1: f64, v1: f64, t: f64) -> f64 { + const t3 = t * t * t; + const t4 = t3 * t; + const t5 = t4 * t; + const h0 = 1.0 - 10.0 * t3 + 15.0 * t4 - 6.0 * t5; + const h1 = t - 6.0 * t3 + 8.0 * t4 - 3.0 * t5; + const h4 = 0.0 - 4.0 * t3 + 7.0 * t4 - 3.0 * t5; + const h5 = 10.0 * t3 - 15.0 * t4 + 6.0 * t5; + return h0 * p0 + h1 * v0 + h4 * v1 + h5 * p1; +} + +// The slopes of the Hermite's bases per unit of t: j 1 the start speed's, 4 the end speed's, 5 the +// end place's. +fn hermite_slope(j: usize, t: f64) -> f64 { + const t2 = t * t; + const t3 = t2 * t; + const t4 = t3 * t; + if (j == 1) { return 1.0 - 18.0 * t2 + 32.0 * t3 - 15.0 * t4; } + if (j == 4) { return 0.0 - 12.0 * t2 + 28.0 * t3 - 15.0 * t4; } + return 30.0 * t2 - 60.0 * t3 + 30.0 * t4; +} + +// A flight's zoom at t, 0..1, before the hover: the Hermite in ln z less the pull-back. +fn flight_z(lz0: f64, r0: f64, lz1: f64, r1: f64, dip: f64, t: f64) -> f64 { + const b = t * (1.0 - t); + return @exp(cam_hermite(lz0, r0, lz1, r1, t) - dip * 64.0 * b * b * b); +} + +pub const FLIGHT_PANELS : i32 = 24; // Simpson panels per integral, even + +// The integral from a to b of (wq h5' + w0 h1' + w1 h4') / z over a flight, by Simpson's rule: how +// far the centre moves, world px, for a screen-px Hermite with those weights. +fn flight_sum(lz0: f64, r0: f64, lz1: f64, r1: f64, dip: f64, a: f64, b: f64, wq: f64, w0: f64, w1: f64) -> f64 { + if (b <= a) { return 0.0; } + const h = (b - a) / @as(f64, @floatFromInt(FLIGHT_PANELS)); + var acc : f64 = 0.0; + var i : i32 = 0; + var odd : bool = false; + while (i <= FLIGHT_PANELS) { + const x = a + h * @as(f64, @floatFromInt(i)); + var w : f64 = 2.0; + if (odd) { w = 4.0; } + if (i == 0 || i == FLIGHT_PANELS) { w = 1.0; } + const g = wq * hermite_slope(5, x) + w0 * hermite_slope(1, x) + w1 * hermite_slope(4, x); + acc = acc + w * g / flight_z(lz0, r0, lz1, r1, dip, x); + odd = (odd == false); + i = i + 1; + } + return acc * h / 3.0; +} + +// How far shot s's flight pulls back at its middle, in ln z: a drone climbs for a long way and +// barely for a short one. +fn flight_dip(o: usize, s: usize) -> f64 { + const st = @as(f64, @floatFromInt(SHOT_START[s])); + const e = st + flight_f(s); + var z : f64 = drift(o, s - 1, st, 2); + if (drift(o, s, e, 2) < z) { z = drift(o, s, e, 2); } + const dx = drift(o, s, e, 0) - drift(o, s - 1, st, 0); + const dy = drift(o, s, e, 1) - drift(o, s - 1, st, 1); + const w = @as(f64, @floatFromInt(frame_w(o))); + const h = @as(f64, @floatFromInt(frame_h(o))); + return FLIGHT_DIP * @log(1.0 + @sqrt(dx * dx + dy * dy) * z / @sqrt(w * w + h * h)); +} + +// The drone's hover at frame f, -1..1, per component: slow sines no two of which keep step. +fn cam_hover(f: f64, k: usize) -> f64 { + if (k == 0) { return 0.6 * @sin(0.0517 * f + 0.3) + 0.4 * @sin(0.0331 * f + 1.9); } + if (k == 1) { return 0.6 * @sin(0.0443 * f + 2.4) + 0.4 * @sin(0.0287 * f + 0.7); } + return @sin(0.0389 * f + 1.1); +} + +// Shot s's camera with no flight: its drift, hovering. What the reel would show if it cut. +pub fn cam_still(o: usize, s: usize, f: f64, k: usize) -> f64 { + const z = drift(o, s, f, 2) * (1.0 + HOVER_Z * cam_hover(f, 2)); + if (k == 2) { return z; } + return drift(o, s, f, k) + HOVER_PX * cam_hover(f, k) / z; } -// The camera of shot s at frame f, component k. f may lie outside the shot: the pose is held at -// its ends, so the frame before a cut is seen through the new shot's camera and a cut carries no -// motion blur. +// The camera of shot s at frame f, component k: the flight into the shot, then its drift, both +// hovering. Before the shot's first frame the flight is held at its start. pub fn pose(o: usize, s: usize, f: f64, k: usize) -> f64 { - const own = drift(o, s, f, k); - const n = entry_frames(s); - if (s == 0 || n == 0.0) { - return own; - } + const n = flight_f(s); const st = @as(f64, @floatFromInt(SHOT_START[s])); - const w = smoother((f - st) / n); - const from = drift(o, s - 1, st, k); - if (k == 2) { - return from * @exp(w * @log(own / from)); + if (s == 0 || n == 0.0 || f >= st + n) { + return cam_still(o, s, f, k); } - return from + (own - from) * w; + const t = clamp01((f - st) / n); + const lz0 = drift_log(o, s - 1, st, 2); + const r0 = drift_rate(o, s - 1, 2) * n; + const lz1 = drift_log(o, s, st + n, 2); + const r1 = drift_rate(o, s, 2) * n; + const dip = flight_dip(o, s); + const z = flight_z(lz0, r0, lz1, r1, dip, t) * (1.0 + HOVER_Z * cam_hover(f, 2)); + if (k == 2) { return z; } + // the centre's Hermite is drawn in screen px: q' = z c' eases from the last drift's speed to + // the next one's, with the height Q that lands it on the next drift + const p0 = drift_log(o, s - 1, st, k); + const p1 = drift_log(o, s, st + n, k); + const w0 = @exp(lz0) * drift_rate(o, s - 1, k) * n; + const w1 = @exp(lz1) * drift_rate(o, s, k) * n; + const i5 = flight_sum(lz0, r0, lz1, r1, dip, 0.0, 0.5, 1.0, 0.0, 0.0) + flight_sum(lz0, r0, lz1, r1, dip, 0.5, 1.0, 1.0, 0.0, 0.0); + const i1 = flight_sum(lz0, r0, lz1, r1, dip, 0.0, 0.5, 0.0, 1.0, 0.0) + flight_sum(lz0, r0, lz1, r1, dip, 0.5, 1.0, 0.0, 1.0, 0.0); + const i4 = flight_sum(lz0, r0, lz1, r1, dip, 0.0, 0.5, 0.0, 0.0, 1.0) + flight_sum(lz0, r0, lz1, r1, dip, 0.5, 1.0, 0.0, 0.0, 1.0); + const q = (p1 - p0 - w0 * i1 - w1 * i4) / i5; + var c : f64 = p1 - flight_sum(lz0, r0, lz1, r1, dip, t, 1.0, q, w0, w1); + if (t <= 0.5) { c = p0 + flight_sum(lz0, r0, lz1, r1, dip, 0.0, t, q, w0, w1); } + return c + HOVER_PX * cam_hover(f, k) / z; } pub fn cam(o: usize, f: i32, k: usize) -> f64 { return pose(o, shot_of(f), @as(f64, @floatFromInt(f)), k); } +// The camera at frame f, flying (fly) or cutting from drift to drift: the second is the probe the +// no-jerk test bites with. +fn cam_or_cut(o: usize, f: i32, k: usize, fly: bool) -> f64 { + if (fly) { return cam(o, f, k); } + return cam_still(o, shot_of(f), @as(f64, @floatFromInt(f)), k); +} + // World to screen and back, through shot s's camera at frame f. pub fn to_screen_x(o: usize, s: usize, f: f64, x: f64) -> f64 { return (x - pose(o, s, f, 0)) * pose(o, s, f, 2) + 0.5 * @as(f64, @floatFromInt(frame_w(o))); @@ -174,9 +294,9 @@ pub fn visible(o: usize, s: usize, f: f64, k: usize) -> f64 { } // True when the world box (x0, y0)-(x1, y1) is on screen, with a margin of m screen px, for every -// frame of shot s after its entry move. +// frame of shot s after its flight. pub fn framed(o: usize, s: usize, x0: f64, y0: f64, x1: f64, y1: f64, m: f64) -> bool { - var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s); + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + flight_f(s); const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); var ok : bool = true; while (f < e) { @@ -189,9 +309,9 @@ pub fn framed(o: usize, s: usize, x0: f64, y0: f64, x1: f64, y1: f64, m: f64) -> } // The largest distance, screen px, any point of the world box moves between two frames of a shot -// once its entry move is over: a slow push, not a pan. +// once its flight is over: a slow push, not a pan. pub fn drift_px(o: usize, s: usize) -> f64 { - var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s) + 1.0; + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + flight_f(s) + 1.0; const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); var m : f64 = 0.0; while (f < e) { @@ -212,9 +332,49 @@ pub fn drift_px(o: usize, s: usize) -> f64 { return m; } +// Where the world point under screen point (px, py) at frame f is on screen at frame g, k 0 x, +// 1 y, through the flying camera or the cutting one. +fn seen_at(o: usize, f: i32, g: i32, px: f64, py: f64, k: usize, fly: bool) -> f64 { + var p : f64 = px; + var half : f64 = 0.5 * @as(f64, @floatFromInt(frame_w(o))); + if (k == 1) { + p = py; + half = 0.5 * @as(f64, @floatFromInt(frame_h(o))); + } + const w = (p - half) / cam_or_cut(o, f, 2, fly) + cam_or_cut(o, f, k, fly); + return (w - cam_or_cut(o, g, k, fly)) * cam_or_cut(o, g, 2, fly) + half; +} + +// How hard the picture is shoved at frame f: the largest screen acceleration, px per frame +// squared, of the world points under the safe rect's corners (when vel is false), or their +// largest speed, px per frame (when vel is true). A cut shoves the picture its whole way at once. +pub fn shove_px(o: usize, f: i32, fly: bool, vel: bool) -> f64 { + var m : f64 = 0.0; + var c : usize = 0; + while (c < 4) { + var px : f64 = safe_f(o, 0); + if (c == 1 || c == 3) { px = safe_f(o, 2); } + var py : f64 = safe_f(o, 1); + if (c >= 2) { py = safe_f(o, 3); } + var dx : f64 = 0.0; + var dy : f64 = 0.0; + if (vel) { + dx = seen_at(o, f, f + 1, px, py, 0, fly) - px; + dy = seen_at(o, f, f + 1, px, py, 1, fly) - py; + } else { + dx = seen_at(o, f, f + 1, px, py, 0, fly) - 2.0 * px + seen_at(o, f, f - 1, px, py, 0, fly); + dy = seen_at(o, f, f + 1, px, py, 1, fly) - 2.0 * py + seen_at(o, f, f - 1, px, py, 1, fly); + } + const d = @sqrt(dx * dx + dy * dy); + if (d > m) { m = d; } + c = c + 1; + } + return m; +} + // ---- the scene tags: the small mono line over each shot ---- -pub const TAG_X : [2]i32 = [60, 80]; -pub const TAG_Y : [2]i32 = [96, 72]; +pub const TAG_X : [2]i32 = [130, 80]; // where a tag lands; it slides in from the right +pub const TAG_Y : [2]i32 = [272, 72]; pub const TAG_FONT : [2]i32 = [30, 28]; pub fn tag_text(s: usize) -> str { @@ -234,10 +394,10 @@ pub fn tag_text(s: usize) -> str { pub const CARDS : usize = 3; pub const CARD_ON : [6]i32 = [98, 176, 272, 344, 392, 450]; // Per frame: x, y, w, h, corner radius, font px of the code line. -pub const CARD_BOX : [12]f64 = [60.0, 1330.0, 960.0, 230.0, 22.0, 34.0, 1180.0, 96.0, 680.0, 200.0, 20.0, 27.0]; +pub const CARD_BOX : [12]f64 = [130.0, 1046.0, 790.0, 208.0, 22.0, 33.0, 1180.0, 96.0, 680.0, 200.0, 20.0, 27.0]; pub const CARD_IN : f64 = 8.0; // frames to rise in pub const CARD_OUT : f64 = 6.0; // frames to go -pub const CARD_RISE : f64 = 36.0; // px it rises as it comes in +pub const CARD_RISE : f64 = 28.0; // px it rises as it comes in pub const PILL_W : f64 = 132.0; pub const PILL_H : f64 = 44.0; @@ -310,10 +470,12 @@ pub fn pill_dy(o: usize) -> f64 { // ---- the interface: where the subject must stay, and where the captions go ---- // Per frame: the safe rect the scene's subject keeps inside (x0, y0, x1, y1), then the caption -// band (x, width, centre line y). Tall: the tags above, the card and the captions below. Wide: -// the scene on the left, an interface column on the right with the card at its top and the -// captions at its foot. -pub const SAFE : [14]f64 = [40.0, 150.0, 1040.0, 1310.0, 60.0, 960.0, 1700.0, 40.0, 130.0, 1140.0, 1040.0, 1180.0, 680.0, 960.0]; +// band (x, width, centre line y). Tall: all of it inside reel_marketing's safe rect (x 130..920, +// y 260..1436), the tags at its top, the scene, then the card and the captions; the frame's top +// 260 and bottom 484 px carry only the stage, under the platforms' own interface. Wide: the scene +// on the left, an interface column on the right with the card at its top and the captions at its +// foot. +pub const SAFE : [14]f64 = [130.0, 330.0, 920.0, 1030.0, 130.0, 790.0, 1356.0, 40.0, 130.0, 1140.0, 1040.0, 1180.0, 680.0, 960.0]; pub fn safe_f(o: usize, k: usize) -> f64 { return SAFE[o * 7 + k]; @@ -332,9 +494,9 @@ pub fn caption_y(o: usize) -> i32 { } // True when the world box (x0, y0)-(x1, y1) lies inside the safe rect for every frame of shot s -// after its entry move, from frame `from` on. +// after its flight, from frame `from` on. pub fn inside(o: usize, s: usize, from: f64, x0: f64, y0: f64, x1: f64, y1: f64) -> bool { - var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s); + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + flight_f(s); if (from > f) { f = from; } const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); var ok : bool = true; @@ -349,7 +511,7 @@ pub fn inside(o: usize, s: usize, from: f64, x0: f64, y0: f64, x1: f64, y1: f64) // The worst overshoot, screen px, of the box past the safe rect over the same frames: a probe for // tuning a pose, 0 when the box is inside. pub fn overshoot(o: usize, s: usize, from: f64, x0: f64, y0: f64, x1: f64, y1: f64) -> f64 { - var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s); + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + flight_f(s); if (from > f) { f = from; } const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); var m : f64 = 0.0; @@ -369,21 +531,28 @@ pub fn overshoot(o: usize, s: usize, from: f64, x0: f64, y0: f64, x1: f64, y1: f // ---- tests ---- -test "the shots tile the reel, in order, 0.6 to 2.5 s each" { +test "the shots tile the reel, in order, 0.6 to 2.5 s each, a new picture inside the reset window" { assert(SHOT_START[0] == 0); assert(SHOT_START[SHOTS] == SCENE_FRAMES); var s : usize = 0; while (s < SHOTS) { assert(shot_len(s) >= 18); assert(shot_len(s) <= 75); + // reel_marketing: the picture changes at least every REEL_RESET_MS + assert(shot_len(s) * 1000 <= REEL_RESET_MS * SCENE_FPS); + // a flight lands inside its shot, and leaves the shot some frames of its own + assert(FLIGHT_F[s] + 4 <= shot_len(s)); + if (s > 0) { + assert(FLIGHT_F[s] >= 12); + } s = s + 1; } assert(shot_of(0) == 0); - assert(shot_of(67) == 0); - assert(shot_of(68) == 1); + assert(shot_of(43) == 0); + assert(shot_of(44) == 1); assert(shot_of(449) == SHOTS - 1); - // the first shot opens on a still frame; it does not enter by a move - assert(SHOT_KIND[0] == CUT); + // the first shot opens on a still frame; it does not fly in + assert(FLIGHT_F[0] == 0); } test "a shot pushes in slowly; only the crane up the column travels, under 30 px a frame" { @@ -403,26 +572,93 @@ test "a shot pushes in slowly; only the crane up the column travels, under 30 px } } -test "a move starts where the last shot ended and arrives at the new shot's own pose" { +test "a flight leaves where the last shot was, at its speed, and lands on the next drift at its" { + const h = 0.001; var o : usize = 0; while (o < 2) { var s : usize = 1; while (s < SHOTS) { const st = @as(f64, @floatFromInt(SHOT_START[s])); - if (SHOT_KIND[s] != CUT) { - var k : usize = 0; - while (k < 3) { - assert(@abs(pose(o, s, st, k) - pose(o, s - 1, st, k)) < 1e-9); - assert(@abs(pose(o, s, st + entry_frames(s), k) - drift(o, s, st + entry_frames(s), k)) < 1e-9); - k = k + 1; - } + const e = st + flight_f(s); + var k : usize = 0; + while (k < 3) { + // the same place at the join + assert(@abs(pose(o, s, st, k) - pose(o, s - 1, st, k)) < 1e-9 * (1.0 + @abs(pose(o, s - 1, st, k)))); + // the same speed on both sides of it + const v_out = (pose(o, s - 1, st, k) - pose(o, s - 1, st - h, k)) / h; + const v_in = (pose(o, s, st + h, k) - pose(o, s, st, k)) / h; + assert(@abs(v_in - v_out) < 1e-3 * (1.0 + @abs(v_out))); + // and the landing: on the drift, at the drift's speed + assert(@abs(pose(o, s, e, k) - cam_still(o, s, e, k)) < 1e-9 * (1.0 + @abs(cam_still(o, s, e, k)))); + const w_in = (pose(o, s, e, k) - pose(o, s, e - h, k)) / h; + const w_out = (cam_still(o, s, e + h, k) - cam_still(o, s, e, k)) / h; + assert(@abs(w_in - w_out) < 1e-3 * (1.0 + @abs(w_out))); + k = k + 1; } s = s + 1; } o = o + 1; } - // a cut changes the picture at once: shot 3 does not look where shot 2 did - assert(@abs(cam(0, 130, 1) - cam(0, 129, 1)) > 300.0); +} + +// The hardest shove and the fastest glide the reel's camera gives the picture, per frame, and the +// cut it replaces. +pub const SHOVE_MAX : f64 = 24.0; // px per frame squared; measured 20.1 tall, 19.9 wide +pub const GLIDE_MAX : f64 = 200.0; // px per frame; measured 164.7 tall, 155.0 wide + +test "the camera never jerks: no frame shoves the picture, where a cut would throw it" { + var o : usize = 0; + while (o < 2) { + var worst : f64 = 0.0; + var fast : f64 = 0.0; + var at : i32 = 0; + var f : i32 = 1; + while (f + 1 < SCENE_FRAMES) { + const a = shove_px(o, f, true, false); + const v = shove_px(o, f, true, true); + if (a > worst) { + worst = a; + at = f; + } + if (v > fast) { fast = v; } + f = f + 1; + } + std.debug.print("frame {d} shove {d:.2} at {d} glide {d:.1}\n", .{ o, worst, at, fast }); + assert(worst < SHOVE_MAX); + assert(fast < GLIDE_MAX); + // the same reel cut from drift to drift: the join into the counter throws the picture + assert(shove_px(o, SHOT_START[3], false, false) > 300.0); + assert(shove_px(o, SHOT_START[3], true, false) < SHOVE_MAX); + o = o + 1; + } + // without its flight shot 3 would start far from where shot 2 ends; with it, one frame is a + // small step + assert(@abs(cam_still(0, 3, 114.0, 1) - cam_still(0, 2, 114.0, 1)) > 300.0); + assert(@abs(cam(0, 114, 1) - cam(0, 113, 1)) < 40.0); +} + +test "a long flight pulls back, a short one barely, and the hover stays small" { + var o : usize = 0; + while (o < 2) { + // up from the floor to the counter is a long way: the drone climbs, and not past its cap + assert(flight_dip(o, 3) > 0.2); + assert(flight_dip(o, 3) < FLIGHT_DIP); + // at the middle of the flight the camera sees more than at either end + const st = @as(f64, @floatFromInt(SHOT_START[3])); + const mid = pose(o, 3, st + 0.5 * flight_f(3), 2); + assert(mid < pose(o, 3, st, 2)); + assert(mid < pose(o, 3, st + flight_f(3), 2)); + o = o + 1; + } + var f : f64 = 0.0; + while (f < 450.0) { + var k : usize = 0; + while (k < 3) { + assert(@abs(cam_hover(f, k)) <= 1.0); + k = k + 1; + } + f = f + 7.0; + } } test "smootherstep starts and stops at rest" { @@ -523,9 +759,24 @@ test "the card's lines, its pill and the caption band keep clear of each other a assert(safe_f(TALL, 3) <= card_f(TALL, 1)); assert(safe_f(WIDE, 2) <= card_f(WIDE, 0)); assert(safe_f(WIDE, 2) <= safe_f(WIDE, 4)); - assert(band_x(TALL) == 60 && band_w(WIDE) == 680 && caption_y(TALL) == 1700); + assert(band_x(TALL) == 130 && band_w(WIDE) == 680 && caption_y(TALL) == 1356); } +test "in the tall cut the scene, the card and the caption band show on every platform" { + // reel_marketing: nothing the reel needs read sits under a platform's buttons or captions + assert(reel_shows(safe_f(TALL, 0), safe_f(TALL, 1), safe_f(TALL, 2), safe_f(TALL, 3))); + // the card, at the lowest point of its rise + assert(reel_shows(card_f(TALL, 0), card_f(TALL, 1), card_f(TALL, 0) + card_f(TALL, 2), card_f(TALL, 1) + card_f(TALL, 3) + CARD_RISE)); + // the caption band, a 124 px line with its 9 px border + const bx = @as(f64, @floatFromInt(band_x(TALL))); + const bw = @as(f64, @floatFromInt(band_w(TALL))); + const cy = @as(f64, @floatFromInt(caption_y(TALL))); + assert(reel_shows(bx, cy - 124.0 / 2.0 - 9.0, bx + bw, cy + 124.0 / 2.0 + 9.0)); + // the bite: the band where it was, centred at 1700, is under the caption and audio line + assert(reel_shows(bx, 1700.0 - 71.0, bx + bw, 1700.0 + 71.0) == false); +} + + test "the safe-rect check bites" { // the white ball at rest is inside shot 1's safe rect; the title, at the top of the stage, is not assert(inside(TALL, 1, 0.0, 826.0, 1572.0, 894.0, 1640.0)); From db6d44edc620993532f646c01f4b65b567c7659d Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 18 Oct 2026 21:13:51 +0700 Subject: [PATCH 2/3] census: re-bless quiet 210 -> 216 and shell 320 -> 322 run steps after #10474 #10474 rewrote auto-merge-ready-prs.yml so the merger runs as TRI CLAW on workflow_run; the rewrite added two run: steps (both handed to bash by the runner, 299 -> 301) and six steps that name a path without a quiet shape. Master's cli-tri went red on that merge; blessed with tri census pin --bless at master 6070e9b58 on the lab, byte-identical. Refs #10469 Co-Authored-By: Claude Opus 5.5 --- tools/census/quiet.txt | 2 +- tools/census/shell.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/census/quiet.txt b/tools/census/quiet.txt index 1814b5a983..512f547e6b 100644 --- a/tools/census/quiet.txt +++ b/tools/census/quiet.txt @@ -2,7 +2,7 @@ GATE STEPS WHOSE PASS SURVIVES THE SUBJECT GOING MISSING workflow files read 67 steps in a quiet shape 33 - named a path but not quiet 210 (--excluded prints them) + named a path but not quiet 216 (--excluded prints them) by shape: failure branch passes 14 `… 2>/dev/null … || echo PASSED` diff --git a/tools/census/shell.txt b/tools/census/shell.txt index 1d773ed370..ffa204d2b1 100644 --- a/tools/census/shell.txt +++ b/tools/census/shell.txt @@ -2,10 +2,10 @@ WHICH INTERPRETER EACH GATE STEP IS HANDED TO, AND WHO SAYS SO workflow files read 67 jobs 91 - run: steps 320 + run: steps 322 who names the shell: - the runner does 299 no container, so bash -eo pipefail + the runner does 301 no container, so bash -eo pipefail a `shell:` key does 6 NOBODY 15 a container and no `shell:` key From 5a7b48ad4d3bf8bd8bb3ebf3cbc7be2661ce1571 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 18 Oct 2026 21:16:45 +0700 Subject: [PATCH 3/3] t27b ledger: max_not_pass 130 -> 132 for token_floor_scenes and token_floor_mux Both were pass at 1ef60ae34 and are now blocked on ExprCall(@sin): the drone flight's hover in token_floor_scenes calls @sin, and token_floor_mux uses scenes. The CI ratchet on f0d69e44e counted 132 non-pass entries against the cap of 130 (OVER CAP 1, every other column 0); the merge from master had dropped this branch's cap edit. Refs #10469 Co-Authored-By: Claude Opus 5.5 --- docs/reports/t27b_expectations.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 12c0e5cc1a..66122958ee 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -12,7 +12,7 @@ "reference-bug": "the reference passes it for the wrong reason; t27b is right not to", "n/a": "outside what t27b is for (say why in the PR that sets it)" }, - "max_not_pass": 130, + "max_not_pass": 132, "entries": [ {"path": "specs/a/b_c.t27", "t27b": "pass"}, {"path": "specs/a_b/c.t27", "t27b": "pass"},