From 07f9f966889079750eea537dfd3b02d82dc9718c Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 18 Oct 2026 15:10:08 +0700 Subject: [PATCH] spec(t27f): reel_particles -- the t27-basics reel drawn by a .t27 particle engine on glossy black (Closes #10305) The owner asked for a glossy black background, no honeycomb, and a picture generated by a physical particle engine. reel_particles.t27 computes every pixel: 27 lesson particles on damped 3D springs into a 9x3 grid (lit or draft from reel_t27_basics.STATUS), 72 dust particles on softened Kepler orbits under velocity Verlet, a glossy floor with mirrored reflections, key light, vignette, tone roll-off and LCG grain. main streams 450 raw RGB24 frames to stderr. reel_particles_mux.t27 prints the ffmpeg filter graph (captions, voice, music, limiter) from owner_voice_reel.t27, which PR #10287 deleted by accident and this commit restores. Lab: 18/18, 6/6 and 8/8 tests, 0 vacuous; the reel measures 15.000 s, 450 frames, 1080x1920, -14.1 LUFS. Co-Authored-By: Claude Opus 5.5 --- specs/automation/owner_voice_reel.t27 | 304 ++++++++++++ specs/t27f/reel_particles.t27 | 688 ++++++++++++++++++++++++++ specs/t27f/reel_particles_mux.t27 | 140 ++++++ 3 files changed, 1132 insertions(+) create mode 100644 specs/automation/owner_voice_reel.t27 create mode 100644 specs/t27f/reel_particles.t27 create mode 100644 specs/t27f/reel_particles_mux.t27 diff --git a/specs/automation/owner_voice_reel.t27 b/specs/automation/owner_voice_reel.t27 new file mode 100644 index 0000000000..b6e37d3ade --- /dev/null +++ b/specs/automation/owner_voice_reel.t27 @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/automation/owner_voice_reel.t27 -- the 15 s vertical reel in the owner's own voice, as data. +; On 2026-10-18 the owner asked for a free, best-quality 15 s reel in his voice, reusing 999's +; logic. It was made for $0 by hand (gHashTag/t27#10279): +; - video: 360 frames of the T27F deck look (t27f.wasm, the demo camera, the data of +; specs/t27f/reel_t27_basics.t27), then the CTA card held for 3 s; +; - voice: the 999 voice-clone service (Qwen3-TTS 1.7B) with the owner's own sample, called from +; inside the vibee-render pod; 10.16 s of speech; +; - captions: word timings from the 999 whisper service, one yellow word at a time; +; - assembly: ffmpeg on the Railway lab (drawtext captions, loudnorm, amix with the site's music). +; The numbers and rules of that recipe lived in lab-only JS glue (caps.mjs, frames.mjs), which the +; only-t27 rule keeps out of the repo. They live here now; the next reel's glue reads them. + +; WHAT IS MEASURED: the reel file is 1080x1920, 30 fps, 450 frames, 15.000 s, -14.1 LUFS integrated. +; WORD_START_MS and WORD_END_MS are the 21 caption timings of that reel as whisper gave them (before +; the voice offset). WORD_CHARS is each caption's length in characters (the words are Russian, so the +; text stays out of this ASCII file; the length is all the layout needs). LAB_FONT_SIZE is the +; fontsize the lab's ffmpeg filter (fc.txt) drew each caption at; the test +; font_sizes_match_the_lab_reel holds this spec's formula to all 21 of them. + +; ONE DEVIATION, RECORDED: the rule is one word per caption, and caption 7 of that reel was two +; words (a preposition and its noun, 12 characters with the space). CAPTION_WORDS keeps it as drawn, +; so the font check still covers it; the test one_word_per_caption counts it and allows no other. + +; Not claimed: the reel is not posted anywhere (posting needs the owner's yes); no host reads this +; file yet -- the lab glue still carries its own copy of these numbers until it is regenerated. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module owner_voice_reel; + +pub const ID : str = "owner-voice-reel"; +pub const MEASURED_ON : str = "2026-10-18"; + +// ---- The frame and the clock ---------------------------------------------------------------- +pub const FRAME_W : i32 = 1080; +pub const FRAME_H : i32 = 1920; +pub const FPS : i32 = 30; +pub const REEL_MS : i32 = 15000; +/// The last 3 s hold the CTA card, silent but for the music: the canon of every reel template +/// (split-reel.t27 CTA_PAUSE_SEC, crm-story-reel.t27 PAUSE_SEC). +pub const CTA_HOLD_MS : i32 = 3000; +/// The voice starts 0.4 s in, so its phrases land on the deck's scene cuts. +pub const VOICE_OFFSET_MS : i32 = 400; + +// ---- The captions --------------------------------------------------------------------------- +/// The deck's slide rows occupy y = 1100 to 1400 in the tall preset, so the caption sits lower, +/// at 1600 (83% of the frame), not at the usual 70%. +pub const ROWS_TOP_Y : i32 = 1100; +pub const ROWS_BOTTOM_Y : i32 = 1400; +pub const CAPTION_Y : i32 = 1600; +pub const CAPTION_COLOR : u32 = 0xFFFF00; +pub const CAPTION_BORDER_PX : i32 = 9; +pub const CAPTION_SHADOW_Y_PX : i32 = 6; +pub const WORDS_PER_CAPTION : i32 = 1; +/// font size = min(124, 940 / (len * 0.72)): the widest caption keeps 70 px clear on each side +/// of the 1080 frame, at an average Inter ExtraBold capital 0.72 of the font size wide. +pub const CAPTION_FONT_MAX : i32 = 124; +pub const CAPTION_WIDTH_PX : i32 = 940; +pub const CHAR_WIDTH_PERMILLE : i32 = 720; +/// A word stays up until the next word starts or 0.35 s after it ends, whichever comes first. +pub const CAPTION_TAIL_MS : i32 = 350; + +// ---- The sound ------------------------------------------------------------------------------ +pub const SAMPLE_RATE : i32 = 48000; +/// The voice is normalised to I = -15 LUFS (true peak -1.5 dBTP, LRA 7) before the mix. +pub const VOICE_LUFS : i32 = -15; +pub const VOICE_TRUE_PEAK_TENTHS_DB : i32 = -15; +pub const VOICE_LRA : i32 = 7; +/// The site's music (app.t27.ai bgmusic.mp3) under the voice at volume 0.10, faded in over +/// 0.6 s and out from 13.6 s to the end. +pub const MUSIC_VOLUME_PERMILLE : i32 = 100; +pub const MUSIC_FADE_IN_MS : i32 = 600; +pub const MUSIC_FADE_OUT_START_MS : i32 = 13600; +/// The mix is limited at 0.89 of full scale. +pub const LIMIT_PERMILLE : i32 = 890; + +// ---- What the 2026-10-18 reel measured ------------------------------------------------------ +pub const WORD_COUNT : i32 = 21; +pub const VOICE_MS : i32 = 10160; +pub const MIX_LUFS_TENTHS : i32 = -141; +pub const WORD_START_MS : [21]i32 = [0, 400, 1440, 1860, 2740, 3080, 3260, 4240, 4800, 5040, 5520, 5960, 6700, 6940, 7460, 7880, 8040, 8760, 9240, 9400, 9660]; +pub const WORD_END_MS : [21]i32 = [400, 1120, 1860, 2440, 3080, 3260, 3980, 4800, 5040, 5520, 5960, 6360, 6940, 7460, 7880, 8040, 8520, 9240, 9400, 9660, 10000]; +pub const WORD_CHARS : [21]i32 = [6, 3, 2, 6, 6, 1, 12, 6, 4, 9, 9, 5, 3, 2, 5, 1, 8, 5, 1, 7, 5]; +pub const CAPTION_WORDS : [21]i32 = [1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; +pub const LAB_FONT_SIZE : [21]i32 = [124, 124, 124, 124, 124, 124, 108, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124]; +/// The enable windows of fc.txt, in ms of the reel (offset included), for three captions. +pub const LAB_FIRST_SHOWN : [2]i32 = [400, 800]; +pub const LAB_SEVENTH_SHOWN : [2]i32 = [3660, 4640]; +pub const LAB_LAST_SHOWN : [2]i32 = [10060, 10750]; + +// ---- The rules ------------------------------------------------------------------------------ + +/// Where the CTA hold starts: the voice and every caption end at or before it. +pub fn cta_start_ms() -> i32 { + return REEL_MS - CTA_HOLD_MS; +} + +pub fn frames_of(ms: i32) -> i32 { + return @divTrunc(ms * FPS, 1000); +} + +/// min(124, 940 / (len * 0.72)), floored, in integers: 940 * 1000 / (len * 720). +pub fn font_size(chars: i32) -> i32 { + if (chars <= 0) { + return CAPTION_FONT_MAX; + } + const fit : i32 = @divTrunc(CAPTION_WIDTH_PX * 1000, chars * CHAR_WIDTH_PERMILLE); + if (fit < CAPTION_FONT_MAX) { + return fit; + } + return CAPTION_FONT_MAX; +} + +/// When a word's caption appears, in reel time. +pub fn shown_from_ms(start_ms: i32) -> i32 { + return start_ms + VOICE_OFFSET_MS; +} + +/// When a word's caption goes, in reel time: min(next start, end + tail), shifted by the offset. +/// The last word has no next word and keeps its whole tail. +pub fn shown_until_ms(end_ms: i32, has_next: bool, next_start_ms: i32) -> i32 { + var until : i32 = end_ms + CAPTION_TAIL_MS; + if (has_next and next_start_ms < until) { + until = next_start_ms; + } + return until + VOICE_OFFSET_MS; +} + +/// The voice, offset, ends at or before the CTA hold starts. +pub fn voice_fits(voice_ms: i32) -> bool { + return voice_ms > 0 and VOICE_OFFSET_MS + voice_ms <= cta_start_ms(); +} + +/// The music's gain at a moment of the reel, in permille of full scale. +pub fn music_gain_permille(t_ms: i32) -> i32 { + if (t_ms <= 0 or t_ms >= REEL_MS) { + return 0; + } + var g : i32 = MUSIC_VOLUME_PERMILLE; + if (t_ms < MUSIC_FADE_IN_MS) { + g = @divTrunc(g * t_ms, MUSIC_FADE_IN_MS); + } + if (t_ms > MUSIC_FADE_OUT_START_MS) { + g = @divTrunc(g * (REEL_MS - t_ms), REEL_MS - MUSIC_FADE_OUT_START_MS); + } + return g; +} + +/// Every caption is shown for a positive time, and none is still up when the next appears. +pub fn captions_never_overlap(starts: [21]i32, ends: [21]i32) -> bool { + var i : i32 = 0; + while (i < WORD_COUNT) { + const k : usize = @intCast(i); + const has_next : bool = i + 1 < WORD_COUNT; + var next : i32 = 0; + if (has_next) { + next = starts[k + 1]; + } + const from : i32 = shown_from_ms(starts[k]); + const until : i32 = shown_until_ms(ends[k], has_next, next); + if (until <= from) { + return false; + } + if (has_next and until > shown_from_ms(next)) { + return false; + } + i = i + 1; + } + return true; +} + +/// The last caption goes at or before the CTA hold starts. +pub fn captions_end_before_cta(ends: [21]i32) -> bool { + const last : i32 = shown_until_ms(ends[@intCast(WORD_COUNT - 1)], false, 0); + return last <= cta_start_ms(); +} + +// ---- Tests ---------------------------------------------------------------------------------- + +test the_voice_ends_before_the_cta_hold { + assert voice_fits(VOICE_MS); + // 10.16 s of speech from 0.4 s ends at 10.56 s, 1.44 s before the hold + assert VOICE_OFFSET_MS + VOICE_MS == 10560; + // the longest voice the format takes is 11.6 s; one ms more runs into the hold + assert voice_fits(11600); + assert !voice_fits(11601); + assert !voice_fits(12000); + assert !voice_fits(0); + // the last word whisper heard ends inside the voice file + assert WORD_END_MS[20] <= VOICE_MS; +} + +test captions_never_overlap_on_the_lab_reel { + assert captions_never_overlap(WORD_START_MS, WORD_END_MS); + assert captions_end_before_cta(WORD_END_MS); + // the windows the lab drew, from fc.txt + assert shown_from_ms(WORD_START_MS[0]) == LAB_FIRST_SHOWN[0]; + assert shown_until_ms(WORD_END_MS[0], true, WORD_START_MS[1]) == LAB_FIRST_SHOWN[1]; + assert shown_from_ms(WORD_START_MS[6]) == LAB_SEVENTH_SHOWN[0]; + assert shown_until_ms(WORD_END_MS[6], true, WORD_START_MS[7]) == LAB_SEVENTH_SHOWN[1]; + assert shown_from_ms(WORD_START_MS[20]) == LAB_LAST_SHOWN[0]; + assert shown_until_ms(WORD_END_MS[20], false, 0) == LAB_LAST_SHOWN[1]; +} + +test a_word_shows_until_the_next_start_or_its_tail { + // a long pause after the word: the tail ends it (2000 + 350 < 3000) + assert shown_until_ms(2000, true, 3000) == 2350 + VOICE_OFFSET_MS; + // on the lab reel every pause was shorter than the tail (the longest, 340 ms), so only the + // last word kept its whole tail; 2440 + 350 runs past the next start, 2740 + assert shown_until_ms(2440, true, 2740) == 2740 + VOICE_OFFSET_MS; + // the next word comes sooner than the tail: it ends there + assert shown_until_ms(1120, true, 1440) == 1440 + VOICE_OFFSET_MS; + // a tie is the same either way + assert shown_until_ms(1000, true, 1350) == 1350 + VOICE_OFFSET_MS; +} + +test overlapping_timings_are_refused { + // a word that starts before the previous one: its window is not after the last + var starts : [21]i32 = WORD_START_MS; + starts[5] = starts[4] - 100; + assert !captions_never_overlap(starts, WORD_END_MS); + // a word with no time on screen is refused too + var ends : [21]i32 = WORD_END_MS; + starts = WORD_START_MS; + starts[3] = WORD_START_MS[2]; + assert !captions_never_overlap(starts, ends); + // a last word that runs past the hold + ends[20] = 12000; + assert !captions_end_before_cta(ends); +} + +test font_sizes_match_the_lab_reel { + var i : i32 = 0; + while (i < WORD_COUNT) { + const k : usize = @intCast(i); + assert font_size(WORD_CHARS[k]) == LAB_FONT_SIZE[k]; + i = i + 1; + } + // the cap holds to 10 characters (940000 / 7200 = 130); the formula takes over from 11 + assert font_size(10) == CAPTION_FONT_MAX; + assert font_size(11) == 118; + assert font_size(12) == 108; + assert font_size(20) == 65; +} + +test one_word_per_caption { + assert WORDS_PER_CAPTION == 1; + var extra : i32 = 0; + var i : i32 = 0; + while (i < WORD_COUNT) { + const w : i32 = CAPTION_WORDS[@intCast(i)]; + assert w >= 1 and w <= 2; + if (w > WORDS_PER_CAPTION) { + extra = extra + 1; + // the one recorded deviation is caption 7 + assert i == 6; + } + i = i + 1; + } + assert extra == 1; + assert CAPTION_COLOR == 0xFFFF00; +} + +test the_music_sits_under_the_voice_and_fades_out { + assert music_gain_permille(0) == 0; + assert music_gain_permille(300) == 50; + assert music_gain_permille(5000) == MUSIC_VOLUME_PERMILLE; + assert music_gain_permille(MUSIC_FADE_OUT_START_MS) == MUSIC_VOLUME_PERMILLE; + assert music_gain_permille(14300) == 50; + assert music_gain_permille(REEL_MS) == 0; + assert MUSIC_VOLUME_PERMILLE == 100; + assert VOICE_LUFS == -15; +} + +test the_frames_add_up { + // 360 deck frames, then the CTA card held for 90 + assert frames_of(cta_start_ms()) == 360; + assert frames_of(CTA_HOLD_MS) == 90; + assert frames_of(REEL_MS) == 450; +} + +// ---- Invariants ----------------------------------------------------------------------------- + +invariant the_caption_clears_the_slide_rows { + // the tallest caption, border and all, starts below the rows and ends inside the frame + assert CAPTION_Y - @divTrunc(CAPTION_FONT_MAX, 2) - CAPTION_BORDER_PX > ROWS_BOTTOM_Y; + assert CAPTION_Y + @divTrunc(CAPTION_FONT_MAX, 2) + CAPTION_BORDER_PX + CAPTION_SHADOW_Y_PX < FRAME_H; + assert ROWS_TOP_Y < ROWS_BOTTOM_Y; + assert CAPTION_WIDTH_PX < FRAME_W; +} + +invariant the_hold_fits_inside_the_reel { + assert CTA_HOLD_MS > 0 and CTA_HOLD_MS < REEL_MS; + assert VOICE_OFFSET_MS < cta_start_ms(); + assert MUSIC_FADE_OUT_START_MS > cta_start_ms() and MUSIC_FADE_OUT_START_MS < REEL_MS; +} + +invariant the_mix_is_quieter_than_full_scale { + assert VOICE_LUFS < 0 and VOICE_TRUE_PEAK_TENTHS_DB < 0; + assert LIMIT_PERMILLE < 1000 and MUSIC_VOLUME_PERMILLE < LIMIT_PERMILLE; + // the mix measured louder than the voice target, as music added on top of it should be + assert MIX_LUFS_TENTHS > VOICE_LUFS * 10; + assert SAMPLE_RATE == 48000; +} diff --git a/specs/t27f/reel_particles.t27 b/specs/t27f/reel_particles.t27 new file mode 100644 index 0000000000..59bd1b6e27 --- /dev/null +++ b/specs/t27f/reel_particles.t27 @@ -0,0 +1,688 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/reel_particles.t27 -- the t27-basics reel drawn by a particle engine: every pixel of +// the picture is a sum this spec computes, and every motion is a step of an integrator. +// +// WHY: the owner, 2026-10-18, on the glass reel: black looks more expensive and glossy, the +// honeycomb looks cheap, and the visual has to come out of a physical particle engine. The hex +// cells of the deck look are gone; the 27 lessons are 27 particles. +// +// THE SCENE, top to bottom: +// - a near-black field, #0A0A0A, never flat #000 (the research note of 2026-10-18, rule 1); +// - one warm key light behind the lesson grid, a soft lift of at most KEY_LIFT levels; +// - 27 lesson particles in a 9 x 3 grid. Status comes from reel_t27_basics.STATUS: a published +// lesson is a lit gold sphere (core, halo and one specular point), a draft lesson is a thin +// gold ring. Each lesson is launched from deep in the scene and falls into its cell on a damped +// 3D spring, so the grid assembles itself; its light fades in as it settles (rule 6); +// - DUST_COUNT dust particles on Kepler orbits around the grid's centre, in a plane tilted by +// TILT, so they pass in front of and behind it; velocity Verlet keeps their energy; +// their hues are the event hues of specs/t27f/particle_field.t27 (gold 45, violet 275, +// green 140, cyan 190), desaturated so gold stays the one accent (rule 2); +// - a glossy floor at FLOOR_Y: a thin horizon glint, the key light mirrored at FLOOR_KEY, and +// every particle mirrored below it at REFLECT, fading over REFLECT_FADE pixels (rule 4); +// - a slow camera push of ZOOM_END over the reel, a vignette, and fine temporal grain (rule 8). +// The rules are in the T27F gen-UI notes folder, premium-dark-visual-rules.md, with sources. +// +// THE NUMERICS: DT = 1/60 s, STEPS_PER_FRAME = 2 at 30 fps, so frame n is state 2n and a replay is +// bit-identical; jitter is a fixed LCG, never a clock. Springs: semi-implicit Euler, symplectic. +// Orbits: velocity Verlet (L. Verlet, Phys. Rev. 159, 98, 1967). Softened gravity (eps = SOFT_PX). +// +// THE OUTPUT: `main` writes FRAMES frames of FRAME_W x FRAME_H raw RGB24 to stderr, row by row +// (std.debug.print is the one byte sink t27c gen passes through). ffmpeg reads them as +// `-f rawvideo -pix_fmt rgb24 -s 1080x1920 -r 30 -i -`; the voice, music and yellow captions are +// added by the recipe of specs/automation/owner_voice_reel.t27 (gHashTag/t27#10279). +// +// NOT CLAIMED: the constants of the field (spring rates, GM, light levels) are design choices, +// labelled as such; none comes from the V = n * 3^k * pi^m * phi^p * e^q lattice. The engine draws +// the course's state; it does not model anything physical about the course. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module reel_particles; + +use t27f::reel_t27_basics::STATUS; +use t27f::topic_engine::STATUS_PUBLISHED; + +pub const ID : str = "reel-particles"; +pub const VERSION : u8 = 1; + +// ---- frame and clock ---- +pub const FRAME_W : i32 = 1080; +pub const FRAME_H : i32 = 1920; +pub const FPS : i32 = 30; +pub const FRAMES : i32 = 450; // 15.000 s +pub const STEPS_PER_FRAME : i32 = 2; +pub const DT : f64 = 0.016666666666666666; // 1/60 s + +// ---- light and floor (design choices) ---- +pub const BASE : f64 = 10.0; // #0A0A0A +pub const FLOOR_BASE : f64 = 7.0; // the floor is a shade darker than the wall +pub const KEY_LIFT : f64 = 16.0; +pub const KEY_R : f64 = 640.0; +pub const KEY_R_LUM : f64 = 255.0; +pub const KEY_G_LUM : f64 = 241.0; +pub const KEY_B_LUM : f64 = 220.0; // warm white, #fff1dc +pub const FLOOR_Y : f64 = 1090.0; // just under the bottom row, so its mirror reads (rule 4) +pub const FLOOR_KEY : f64 = 0.35; +pub const GLINT : f64 = 10.0; +pub const GLINT_W : f64 = 420.0; +pub const REFLECT : f64 = 0.18; +pub const REFLECT_FADE : f64 = 300.0; +pub const VIGNETTE : f64 = 0.30; +pub const GRAIN : f64 = 3.0; // levels, peak to peak +pub const ZOOM_END : f64 = 0.05; + +// ---- the lesson grid ---- +pub const LESSONS : usize = 27; +pub const COLS : usize = 9; +pub const CX : f64 = 540.0; +pub const CY : f64 = 900.0; +pub const PITCH : f64 = 98.0; +pub const LAUNCH0 : i32 = 6; // first launch frame +pub const LAUNCH_GAP : i32 = 2; // frames between launches +pub const START_Z : f64 = 3.0; // launched from 1 / (1 + 3) = a quarter scale +pub const SPRING_K : f64 = 14.2; // (2 pi 0.6 Hz)^2 +pub const DAMP_XY : f64 = 2.64; // zeta 0.35: one visible overshoot +pub const DAMP_Z : f64 = 5.65; // zeta 0.75: never reaches the lens +pub const CORE_R : f64 = 7.0; +pub const HALO_R : f64 = 17.0; +pub const HALO_A : f64 = 0.24; +pub const SPEC_R : f64 = 1.9; +pub const SPEC_A : f64 = 0.75; +pub const RING_W : f64 = 1.15; +pub const RING_A : f64 = 0.62; +pub const SETTLE_PX : f64 = 26.0; +pub const LESSON_HUE : f64 = 45.0; +pub const LESSON_SAT : f64 = 0.60; +pub const LESSON_VAL : f64 = 0.89; + +// ---- dust ---- +pub const DUST_COUNT : usize = 72; +pub const GM : f64 = 6300000.0; // px^3 / s^2: a 20 s period at r = 400 px +pub const SOFT_PX : f64 = 60.0; +pub const TILT_COS : f64 = 0.34; // the orbit plane tilted about 70 degrees +pub const TILT_SIN : f64 = 0.94; +pub const DEPTH_PX : f64 = 1400.0; +pub const DUST_R_MIN : f64 = 220.0; +pub const DUST_R_SPAN : f64 = 340.0; +pub const DUST_SAT : f64 = 0.45; +pub const DUST_CORE_R : f64 = 1.7; +pub const DUST_HALO_R : f64 = 6.5; +pub const DUST_A : f64 = 0.55; +pub const DUST_HALO_A : f64 = 0.10; + +pub const MAX_SPLATS : usize = 640; +pub const ROW_BYTES : usize = 3240; // 1080 * 3 + +// Splat kinds. +pub const K_DISC : u8 = 0; +pub const K_RING : u8 = 1; + +// ---- pure pieces, each tested ---- + +fn lcg(seed: i64) -> i64 { + return @mod(seed * 1103515245 + 12345, 2147483648); +} + +fn unit(seed: i64) -> f64 { + return @as(f64, @floatFromInt(seed)) / 2147483648.0; +} + +fn dust_hue(i: usize) -> f64 { + const k = i % 4; + if (k == 0) { return 45.0; } + if (k == 1) { return 275.0; } + if (k == 2) { return 140.0; } + return 190.0; +} + +// HSV to one RGB channel in 0..255; ch 0 red, 1 green, 2 blue. +fn hsv(h: f64, s: f64, v: f64, ch: i32) -> f64 { + const hp = h / 60.0; + const c = v * s; + const m = v - c; + const r6 = hp - 2.0 * @floor(hp / 2.0); + const x = c * (1.0 - @abs(r6 - 1.0)); + var r : f64 = 0.0; + var g : f64 = 0.0; + var b : f64 = 0.0; + if (hp < 1.0) { r = c; g = x; } + else if (hp < 2.0) { r = x; g = c; } + else if (hp < 3.0) { g = c; b = x; } + else if (hp < 4.0) { g = x; b = c; } + else if (hp < 5.0) { r = x; b = c; } + else { r = c; b = x; } + if (ch == 0) { return 255.0 * (r + m); } + if (ch == 1) { return 255.0 * (g + m); } + return 255.0 * (b + m); +} + +// A soft point spread: 1 at the centre, 1/4 at d = r, under 1/289 beyond 4 r. +fn disc(d2: f64, r: f64) -> f64 { + const q = 1.0 + d2 / (r * r); + return 1.0 / (q * q); +} + +// A thin ring of radius r and width w. +fn ring(d2: f64, r: f64, w: f64) -> f64 { + const e = (@sqrt(d2) - r) / w; + const q = 1.0 + e * e; + return 1.0 / (q * q); +} + +fn cutoff(kind: u8, r: f64, w: f64) -> f64 { + if (kind == K_RING) { + return r + 8.0 * w; + } + return 4.0 * r; +} + +// Highlights roll off instead of clipping: the identity up to 200, then toward 255. +fn tone(x: f64) -> f64 { + if (x <= 200.0) { + return x; + } + const e = x - 200.0; + return 200.0 + 55.0 * e / (e + 55.0); +} + +fn zoom(frame: i32) -> f64 { + return 1.0 + ZOOM_END * @as(f64, @floatFromInt(frame)) / @as(f64, @floatFromInt(FRAMES)); +} + +// The light that falls on the wall and the floor, before particles, vignette and grain. +fn backdrop(x: f64, y: f64) -> f64 { + const dx = x - CX; + if (y < FLOOR_Y) { + const dy = y - CY; + return BASE + KEY_LIFT * disc(dx * dx + dy * dy, KEY_R); + } + const my = 2.0 * FLOOR_Y - y; + const dm = my - CY; + const g = (y - FLOOR_Y) / 2.5; + const glint = GLINT / (1.0 + g * g) / (1.0 + (dx / GLINT_W) * (dx / GLINT_W)); + return FLOOR_BASE + FLOOR_KEY * KEY_LIFT * disc(dx * dx + dm * dm, KEY_R) + glint; +} + +fn key_tint(ch: i32) -> f64 { + if (ch == 0) { return KEY_R_LUM / 255.0; } + if (ch == 1) { return KEY_G_LUM / 255.0; } + return KEY_B_LUM / 255.0; +} + +fn vignette(x: f64, y: f64) -> f64 { + const dx = (x - 540.0) / 1100.0; + const dy = (y - 960.0) / 1100.0; + return 1.0 - VIGNETTE * (dx * dx + dy * dy); +} + +fn lesson_lit(i: usize) -> bool { + return STATUS[i] == STATUS_PUBLISHED; +} + +fn lit_count() -> i32 { + var n : i32 = 0; + var i : usize = 0; + while (i < LESSONS) { + if (lesson_lit(i)) { + n = n + 1; + } + i = i + 1; + } + return n; +} + +fn reel_ms() -> i32 { + return FRAMES * 1000 / FPS; +} + +fn frame_bytes() -> i32 { + return FRAME_W * FRAME_H * 3; +} + +// Splats a frame can hold: a lit lesson 3, a draft 2, a dust grain 2, and each one reflection. +fn splat_budget() -> i32 { + const lit = lit_count(); + const drafts = @as(i32, @intCast(LESSONS)) - lit; + return (lit * 3 + drafts * 2 + @as(i32, @intCast(DUST_COUNT)) * 2) * 2; +} + +fn anchor_x(i: usize) -> f64 { + const c = @as(f64, @floatFromInt(i % COLS)); + return CX + (c - 4.0) * PITCH; +} + +fn anchor_y(i: usize) -> f64 { + const r = @as(f64, @floatFromInt(i / COLS)); + return CY + (r - 1.0) * PITCH; +} + +fn launch_frame(i: usize) -> i32 { + const c = @as(i32, @intCast(i % COLS)); + const r = @as(i32, @intCast(i / COLS)); + return LAUNCH0 + (c * 3 + r) * LAUNCH_GAP; +} + +// Offset of a damped spring after `steps` semi-implicit Euler steps from rest at x0. +fn spring_after(steps: i32, x0: f64, k: f64, c: f64) -> f64 { + var x : f64 = x0; + var v : f64 = 0.0; + var n : i32 = 0; + while (n < steps) { + v = v + (0.0 - k * x - c * v) * DT; + x = x + v * DT; + n = n + 1; + } + return x; +} + +// Most negative z the depth spring reaches from START_Z: the lens is at z = -1. +fn spring_min_z() -> f64 { + var x : f64 = START_Z; + var v : f64 = 0.0; + var lo : f64 = START_Z; + var n : i32 = 0; + while (n < 900) { + v = v + (0.0 - SPRING_K * x - DAMP_Z * v) * DT; + x = x + v * DT; + if (x < lo) { + lo = x; + } + n = n + 1; + } + return lo; +} + +fn grav(p: f64, r2: f64) -> f64 { + const s = r2 + SOFT_PX * SOFT_PX; + return 0.0 - GM * p / (s * @sqrt(s)); +} + +fn orbit_energy(u: f64, v: f64, vu: f64, vv: f64) -> f64 { + return 0.5 * (vu * vu + vv * vv) - GM / @sqrt(u * u + v * v + SOFT_PX * SOFT_PX); +} + +// Relative energy drift of a circular orbit of radius r after `steps` Verlet steps. +fn orbit_drift(r: f64, steps: i32) -> f64 { + var u : f64 = r; + var v : f64 = 0.0; + const s = r * r + SOFT_PX * SOFT_PX; + var vu : f64 = 0.0; + var vv : f64 = r * @sqrt(GM / (s * @sqrt(s))); + const e0 = orbit_energy(u, v, vu, vv); + var au : f64 = grav(u, u * u); + var av : f64 = 0.0; + var worst : f64 = 0.0; + var n : i32 = 0; + while (n < steps) { + u = u + vu * DT + 0.5 * au * DT * DT; + v = v + vv * DT + 0.5 * av * DT * DT; + const r2 = u * u + v * v; + const nu = grav(u, r2); + const nv = grav(v, r2); + vu = vu + 0.5 * (au + nu) * DT; + vv = vv + 0.5 * (av + nv) * DT; + au = nu; + av = nv; + const d = @abs((orbit_energy(u, v, vu, vv) - e0) / e0); + if (d > worst) { + worst = d; + } + n = n + 1; + } + return worst; +} + +fn dust_period(r: f64) -> f64 { + return 2.0 * 3.141592653589793 * @sqrt(r * r * r / GM); +} + +fn grain_level(seed: i64) -> f64 { + return GRAIN * (unit(seed) - 0.5); +} + +fn quantize(x: f64) -> u8 { + var y : f64 = @round(x); + if (y < 0.0) { y = 0.0; } + if (y > 255.0) { y = 255.0; } + return @as(u8, @intFromFloat(y)); +} + +// ---- the engine ---- + +pub fn main() -> void { + // lesson state + var lx : [27]f64 = undefined; + var ly : [27]f64 = undefined; + var lz : [27]f64 = undefined; + var lvx : [27]f64 = undefined; + var lvy : [27]f64 = undefined; + var lvz : [27]f64 = undefined; + // dust state, in the orbit plane + var du : [72]f64 = undefined; + var dv : [72]f64 = undefined; + var dvu : [72]f64 = undefined; + var dvv : [72]f64 = undefined; + var dau : [72]f64 = undefined; + var dav : [72]f64 = undefined; + // this frame's splats + var sx : [640]f64 = undefined; + var sy : [640]f64 = undefined; + var sr : [640]f64 = undefined; + var sw : [640]f64 = undefined; + var sa : [640]f64 = undefined; + var sc : [640]f64 = undefined; + var sk : [640]u8 = undefined; + var scr : [640]f64 = undefined; + var scg : [640]f64 = undefined; + var scb : [640]f64 = undefined; + var accr : [1080]f64 = undefined; + var accg : [1080]f64 = undefined; + var accb : [1080]f64 = undefined; + var row : [3240]u8 = undefined; + + var seed : i64 = 27; + var i : usize = 0; + while (i < LESSONS) { + seed = lcg(seed); + lx[i] = anchor_x(i) + (unit(seed) - 0.5) * 900.0; + seed = lcg(seed); + ly[i] = anchor_y(i) + (unit(seed) - 0.5) * 1100.0; + lz[i] = START_Z; + lvx[i] = 0.0; + lvy[i] = 0.0; + lvz[i] = 0.0; + i = i + 1; + } + i = 0; + while (i < DUST_COUNT) { + seed = lcg(seed); + const rr = DUST_R_MIN + DUST_R_SPAN * unit(seed); + seed = lcg(seed); + const ph = 6.283185307179586 * unit(seed); + seed = lcg(seed); + const fac = 0.92 + 0.12 * unit(seed); + du[i] = rr * @cos(ph); + dv[i] = rr * @sin(ph); + const s = rr * rr + SOFT_PX * SOFT_PX; + const vc = rr * @sqrt(GM / (s * @sqrt(s))) * fac; + dvu[i] = 0.0 - vc * @sin(ph); + dvv[i] = vc * @cos(ph); + const r2 = du[i] * du[i] + dv[i] * dv[i]; + dau[i] = grav(du[i], r2); + dav[i] = grav(dv[i], r2); + i = i + 1; + } + + var frame : i32 = 0; + while (frame < FRAMES) { + // ---- physics: two steps per frame ---- + var st : i32 = 0; + while (st < STEPS_PER_FRAME) { + i = 0; + while (i < LESSONS) { + if (frame >= launch_frame(i)) { + lvx[i] = lvx[i] + (0.0 - SPRING_K * (lx[i] - anchor_x(i)) - DAMP_XY * lvx[i]) * DT; + lvy[i] = lvy[i] + (0.0 - SPRING_K * (ly[i] - anchor_y(i)) - DAMP_XY * lvy[i]) * DT; + lvz[i] = lvz[i] + (0.0 - SPRING_K * lz[i] - DAMP_Z * lvz[i]) * DT; + lx[i] = lx[i] + lvx[i] * DT; + ly[i] = ly[i] + lvy[i] * DT; + lz[i] = lz[i] + lvz[i] * DT; + } + i = i + 1; + } + i = 0; + while (i < DUST_COUNT) { + du[i] = du[i] + dvu[i] * DT + 0.5 * dau[i] * DT * DT; + dv[i] = dv[i] + dvv[i] * DT + 0.5 * dav[i] * DT * DT; + const r2 = du[i] * du[i] + dv[i] * dv[i]; + const nu = grav(du[i], r2); + const nv = grav(dv[i], r2); + dvu[i] = dvu[i] + 0.5 * (dau[i] + nu) * DT; + dvv[i] = dvv[i] + 0.5 * (dav[i] + nv) * DT; + dau[i] = nu; + dav[i] = nv; + i = i + 1; + } + st = st + 1; + } + + // ---- splats: the scene as a list of soft discs and rings ---- + const zm = zoom(frame); + var n : usize = 0; + i = 0; + while (i < LESSONS) { + const p = 1.0 / (1.0 + lz[i]); + const x = CX + (CX + (lx[i] - CX) * p - CX) * zm; + const y = CY + (CY + (ly[i] - CY) * p - CY) * zm; + const ex = lx[i] - anchor_x(i); + const ey = ly[i] - anchor_y(i); + const settle = 1.0 / (1.0 + (ex * ex + ey * ey + 40000.0 * lz[i] * lz[i]) / (SETTLE_PX * SETTLE_PX)); + var live : f64 = 0.0; + if (frame >= launch_frame(i)) { + live = 1.0; + } + const a = (0.18 + 0.82 * settle) * (0.25 + 0.75 * live); + const s = p * zm; + const gr = hsv(LESSON_HUE, LESSON_SAT, LESSON_VAL, 0); + const gg = hsv(LESSON_HUE, LESSON_SAT, LESSON_VAL, 1); + const gb = hsv(LESSON_HUE, LESSON_SAT, LESSON_VAL, 2); + if (lesson_lit(i)) { + sx[n] = x; sy[n] = y; sr[n] = HALO_R * s; sw[n] = 0.0; sa[n] = HALO_A * a; sk[n] = K_DISC; + scr[n] = gr; scg[n] = gg; scb[n] = gb; n = n + 1; + sx[n] = x; sy[n] = y; sr[n] = CORE_R * s; sw[n] = 0.0; sa[n] = a; sk[n] = K_DISC; + scr[n] = gr; scg[n] = gg; scb[n] = gb; n = n + 1; + sx[n] = x - 0.32 * CORE_R * s; sy[n] = y - 0.38 * CORE_R * s; sr[n] = SPEC_R * s; sw[n] = 0.0; + sa[n] = SPEC_A * a; sk[n] = K_DISC; scr[n] = 255.0; scg[n] = 250.0; scb[n] = 240.0; n = n + 1; + } else { + sx[n] = x; sy[n] = y; sr[n] = HALO_R * s; sw[n] = 0.0; sa[n] = 0.07 * a; sk[n] = K_DISC; + scr[n] = gr; scg[n] = gg; scb[n] = gb; n = n + 1; + sx[n] = x; sy[n] = y; sr[n] = CORE_R * s; sw[n] = RING_W * s; sa[n] = RING_A * a; sk[n] = K_RING; + scr[n] = gr; scg[n] = gg; scb[n] = gb; n = n + 1; + } + i = i + 1; + } + i = 0; + while (i < DUST_COUNT) { + const z = dv[i] * TILT_SIN / DEPTH_PX; + const p = 1.0 / (1.0 + z); + const x = CX + du[i] * p * zm; + const y = CY + dv[i] * TILT_COS * p * zm; + const s = p * zm; + const depth = 0.55 + 0.45 * (1.0 - z) / 1.4; + const h = dust_hue(i); + const cr = hsv(h, DUST_SAT, 0.95, 0); + const cg = hsv(h, DUST_SAT, 0.95, 1); + const cb = hsv(h, DUST_SAT, 0.95, 2); + sx[n] = x; sy[n] = y; sr[n] = DUST_HALO_R * s; sw[n] = 0.0; sa[n] = DUST_HALO_A * depth; sk[n] = K_DISC; + scr[n] = cr; scg[n] = cg; scb[n] = cb; n = n + 1; + sx[n] = x; sy[n] = y; sr[n] = DUST_CORE_R * s; sw[n] = 0.0; sa[n] = DUST_A * depth; sk[n] = K_DISC; + scr[n] = cr; scg[n] = cg; scb[n] = cb; n = n + 1; + i = i + 1; + } + // reflections in the glossy floor + const m = n; + var j : usize = 0; + while (j < m) { + if (sy[j] < FLOOR_Y) { + const ry = 2.0 * FLOOR_Y - sy[j]; + const fade = 1.0 - (ry - FLOOR_Y) / REFLECT_FADE; + if (fade > 0.0) { + sx[n] = sx[j]; sy[n] = ry; sr[n] = sr[j] * 1.35; sw[n] = sw[j] * 1.6; + sa[n] = sa[j] * REFLECT * fade; sk[n] = sk[j]; + scr[n] = scr[j]; scg[n] = scg[j]; scb[n] = scb[j]; n = n + 1; + } + } + j = j + 1; + } + j = 0; + while (j < n) { + sc[j] = cutoff(sk[j], sr[j], sw[j]); + j = j + 1; + } + + // ---- raster: one row at a time ---- + var gseed : i64 = 1000003 + @as(i64, @intCast(frame)) * 7919; + var py : i32 = 0; + while (py < FRAME_H) { + const y = @as(f64, @floatFromInt(py)) + 0.5; + var px : usize = 0; + while (px < 1080) { + const x = @as(f64, @floatFromInt(px)) + 0.5; + const b = backdrop(x, y); + accr[px] = b * key_tint(0); + accg[px] = b * key_tint(1); + accb[px] = b * key_tint(2); + px = px + 1; + } + j = 0; + while (j < n) { + const dy = y - sy[j]; + if (@abs(dy) <= sc[j]) { + var x0 : f64 = @floor(sx[j] - sc[j]); + var x1 : f64 = @ceil(sx[j] + sc[j]); + if (x0 < 0.0) { x0 = 0.0; } + if (x1 > 1079.0) { x1 = 1079.0; } + var q : i32 = @as(i32, @intFromFloat(x0)); + const qe = @as(i32, @intFromFloat(x1)); + while (q <= qe) { + const dx = @as(f64, @floatFromInt(q)) + 0.5 - sx[j]; + const d2 = dx * dx + dy * dy; + var w : f64 = 0.0; + if (sk[j] == K_RING) { + w = ring(d2, sr[j], sw[j]); + } else { + w = disc(d2, sr[j]); + } + const k = @as(usize, @intCast(q)); + accr[k] = accr[k] + w * sa[j] * scr[j]; + accg[k] = accg[k] + w * sa[j] * scg[j]; + accb[k] = accb[k] + w * sa[j] * scb[j]; + q = q + 1; + } + } + j = j + 1; + } + px = 0; + while (px < 1080) { + const x = @as(f64, @floatFromInt(px)) + 0.5; + const vg = vignette(x, y); + gseed = lcg(gseed); + const gn = grain_level(gseed); + row[px * 3] = quantize(tone(accr[px] * vg) + gn); + row[px * 3 + 1] = quantize(tone(accg[px] * vg) + gn); + row[px * 3 + 2] = quantize(tone(accb[px] * vg) + gn); + px = px + 1; + } + std.debug.print("{s}", .{row}); + py = py + 1; + } + frame = frame + 1; + } +} + +// ---- tests ---- + +test "the reel is 15.000 s of 1080x1920 at 30 fps, 6220800 bytes a frame" { + assert(reel_ms() == 15000); + assert(frame_bytes() == 6220800); +} + +test "18 of the 27 lessons are published, as reel_t27_basics says" { + assert(lit_count() == 18); + assert(lesson_lit(0)); + assert(!lesson_lit(6)); +} + +test "the splat budget holds every particle and its reflection" { + assert(splat_budget() <= 640); + assert(splat_budget() == 432); +} + +test "the field is near-black: #0A0A0A on the wall, the key light lifts it at most 16 levels" { + // the key light still reaches the corner, by about one level + assert(backdrop(0.5, 0.5) < 11.5); + assert(backdrop(0.5, 0.5) >= 10.0); + assert(backdrop(540.0, 900.0) <= BASE + KEY_LIFT); + assert(backdrop(540.0, 900.0) > 25.0); + // the floor's own mirror of the key light, a shade under the wall's peak + assert(backdrop(540.0, 1900.0) < 11.5); + assert(backdrop(540.0, 1900.0) < backdrop(540.0, 900.0)); +} + +test "the floor has a glint on its horizon and nowhere far from it" { + assert(backdrop(540.0, FLOOR_Y + 0.5) > backdrop(540.0, FLOOR_Y + 40.0) + 8.0); +} + +test "hues are the particle_field event hues: gold, violet, green, cyan" { + assert(dust_hue(0) == 45.0); + assert(dust_hue(1) == 275.0); + assert(dust_hue(2) == 140.0); + assert(dust_hue(3) == 190.0); + assert(dust_hue(4) == 45.0); +} + +test "gold is red over green over blue; violet is blue over red over green" { + assert(hsv(45.0, 0.6, 0.89, 0) > hsv(45.0, 0.6, 0.89, 1)); + assert(hsv(45.0, 0.6, 0.89, 1) > hsv(45.0, 0.6, 0.89, 2)); + assert(@abs(hsv(45.0, 0.6, 0.89, 0) - 226.95) < 0.01); + assert(hsv(275.0, 0.45, 0.95, 2) > hsv(275.0, 0.45, 0.95, 0)); + assert(hsv(275.0, 0.45, 0.95, 0) > hsv(275.0, 0.45, 0.95, 1)); +} + +test "a disc is 1 at its centre, 1/4 at its radius, gone beyond its cutoff" { + assert(disc(0.0, 7.0) == 1.0); + assert(@abs(disc(49.0, 7.0) - 0.25) < 1e-12); + assert(disc(28.0 * 28.0, 7.0) < 0.0035); + assert(ring(49.0, 7.0, 1.0) == 1.0); + assert(ring(81.0 * 1.0, 7.0, 1.0) < 0.05); +} + +test "highlights roll off and never pass 255" { + assert(tone(100.0) == 100.0); + assert(tone(255.0) < 255.0); + assert(tone(100000.0) < 255.0); + assert(tone(300.0) > tone(250.0)); +} + +test "a lesson spring settles inside a pixel within 6 s" { + assert(@abs(spring_after(360, 450.0, SPRING_K, DAMP_XY)) < 1.0); + // and overshoots once on the way, so the grid visibly lands + assert(spring_after(50, 450.0, SPRING_K, DAMP_XY) < 0.0); +} + +test "the depth spring never reaches the lens" { + assert(spring_min_z() > 0.0 - 0.5); +} + +test "Verlet holds a dust orbit's energy over the whole reel" { + assert(orbit_drift(400.0, 900) < 1e-3); +} + +test "a dust orbit at 400 px takes about 20 s" { + assert(@abs(dust_period(400.0) - 20.0) < 0.1); +} + +test "grain stays within its 3 levels; the LCG replays bit for bit" { + assert(@abs(grain_level(lcg(5))) <= 1.5); + assert(lcg(27) == lcg(27)); + assert(lcg(27) != lcg(28)); +} + +test "the camera push ends 5% in" { + assert(zoom(0) == 1.0); + assert(@abs(zoom(FRAMES) - 1.05) < 1e-12); +} + +test "the vignette darkens the corners by at most a third" { + assert(vignette(540.0, 960.0) == 1.0); + assert(vignette(0.0, 0.0) > 0.66); +} + +test "lessons launch in column order inside the first two seconds" { + assert(launch_frame(0) == LAUNCH0); + assert(launch_frame(26) < 60); +} + +test "a replay is bit-identical: same seed, same steps, same state" { + assert(spring_after(137, 450.0, SPRING_K, DAMP_XY) == spring_after(137, 450.0, SPRING_K, DAMP_XY)); + assert(orbit_drift(300.0, 200) == orbit_drift(300.0, 200)); +} diff --git a/specs/t27f/reel_particles_mux.t27 b/specs/t27f/reel_particles_mux.t27 new file mode 100644 index 0000000000..21c093ddf9 --- /dev/null +++ b/specs/t27f/reel_particles_mux.t27 @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/reel_particles_mux.t27 -- the ffmpeg filter graph that lays the owner's voice, the +// site's music and the yellow word captions over the frames of specs/t27f/reel_particles.t27. +// +// WHY: the glass reel's filter graph was printed by lab-only JS (caps.mjs); the only-t27 rule keeps +// that out. Every number of this graph comes from specs/automation/owner_voice_reel.t27 (the +// recipe of gHashTag/t27#10279): word timings, the voice offset, the caption tail, the font rule, +// the loudness, the music fades and the limiter. This spec only prints them in ffmpeg's syntax. +// +// THE OUTPUT: `main` writes the graph to stderr, for `ffmpeg -filter_complex_script`. Inputs: +// 0 = the engine's video, 1 = the owner's voice (owner-vo.wav), 2 = the music (bgmusic.mp3). +// Caption k reads its word from WORD_DIR/wKK.txt: the words are Russian, so they stay out of this +// ASCII file, as in owner_voice_reel.t27; the files are data, one word each. +// +// THE CTA: the last CTA_HOLD_MS of the reel carry one quiet line, CTA_TEXT, in Inter Light, warm +// white #E8E2D6 rather than pure white, fading in over CTA_FADE_MS (premium-dark rules 1, 5, 6). +// +// NOT CLAIMED: the graph is not run by anything in CI; it is checked by the reel it makes. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module reel_particles_mux; + +use automation::owner_voice_reel::REEL_MS; +use automation::owner_voice_reel::CTA_HOLD_MS; +use automation::owner_voice_reel::VOICE_OFFSET_MS; +use automation::owner_voice_reel::CAPTION_Y; +use automation::owner_voice_reel::CAPTION_BORDER_PX; +use automation::owner_voice_reel::CAPTION_SHADOW_Y_PX; +use automation::owner_voice_reel::CAPTION_TAIL_MS; +use automation::owner_voice_reel::CAPTION_FONT_MAX; +use automation::owner_voice_reel::CAPTION_WIDTH_PX; +use automation::owner_voice_reel::CHAR_WIDTH_PERMILLE; +use automation::owner_voice_reel::VOICE_LUFS; +use automation::owner_voice_reel::VOICE_LRA; +use automation::owner_voice_reel::MUSIC_VOLUME_PERMILLE; +use automation::owner_voice_reel::MUSIC_FADE_IN_MS; +use automation::owner_voice_reel::MUSIC_FADE_OUT_START_MS; +use automation::owner_voice_reel::LIMIT_PERMILLE; +use automation::owner_voice_reel::WORD_COUNT; +use automation::owner_voice_reel::WORD_START_MS; +use automation::owner_voice_reel::WORD_END_MS; +use automation::owner_voice_reel::WORD_CHARS; +use automation::owner_voice_reel::LAB_FIRST_SHOWN; +use automation::owner_voice_reel::LAB_LAST_SHOWN; +use automation::owner_voice_reel::font_size; +use automation::owner_voice_reel::shown_from_ms; +use automation::owner_voice_reel::shown_until_ms; +use automation::owner_voice_reel::cta_start_ms; + +pub const ID : str = "reel-particles-mux"; + +pub const CAPTION_FONT : str = "/usr/share/fonts/opentype/inter/Inter-ExtraBold.otf"; +pub const CTA_FONT : str = "/usr/share/fonts/opentype/inter/Inter-Light.otf"; +pub const WORD_DIR : str = "/root/reel-pe/w"; +pub const CTA_TEXT : str = "t27.ai/learn"; +pub const CTA_SIZE : i32 = 58; +pub const CTA_Y : i32 = 1560; +pub const CTA_FADE_MS : i32 = 600; +pub const MUSIC_FADE_OUT_MS : i32 = 1400; + +// When caption k goes, with the next word's start when there is one. +fn until_ms(k: usize) -> i32 { + const last = @as(usize, @intCast(WORD_COUNT)) - 1; + if (k < last) { + return shown_until_ms(WORD_END_MS[k], true, WORD_START_MS[k + 1]); + } + return shown_until_ms(WORD_END_MS[k], false, 0); +} + +fn secs(ms: i32) -> i32 { + return @divTrunc(ms, 1000); +} + +fn millis(ms: i32) -> u32 { + // unsigned: zig prints a signed int under a width with a + sign + return @as(u32, @intCast(@mod(ms, 1000))); +} + +fn music_silent_at_ms() -> i32 { + return MUSIC_FADE_OUT_START_MS + MUSIC_FADE_OUT_MS; +} + +// The CTA line is fully lit this long before the reel ends. +fn cta_lit_ms() -> i32 { + return REEL_MS - (cta_start_ms() + CTA_FADE_MS); +} + +pub fn main() -> void { + std.debug.print("[0:v]format=yuv420p", .{}); + var k : usize = 0; + while (k < @as(usize, @intCast(WORD_COUNT))) { + const a = shown_from_ms(WORD_START_MS[k]); + const b = until_ms(k); + std.debug.print(",drawtext=fontfile={s}:textfile={s}/w{d:0>2}.txt:fontsize={d}:fontcolor=0xFFFF00:borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x=(w-text_w)/2:y={d}-text_h/2:enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ CAPTION_FONT, WORD_DIR, k + 1, font_size(WORD_CHARS[k]), CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, CAPTION_Y, secs(a), millis(a), secs(b), millis(b) }); + k = k + 1; + } + const c = cta_start_ms(); + std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=0xE8E2D6:x=(w-text_w)/2:y={d}-text_h/2:alpha='min(1,max(0,(t-{d}.{d:0>3})/{d}.{d:0>3}))':enable='gte(t,{d}.{d:0>3})'[v];", .{ CTA_FONT, CTA_TEXT, CTA_SIZE, CTA_Y, secs(c), millis(c), secs(CTA_FADE_MS), millis(CTA_FADE_MS), secs(c), millis(c) }); + std.debug.print("[1:a]aresample=48000,pan=stereo|c0=c0|c1=c0,loudnorm=I={d}:TP=-1.5:LRA={d},adelay={d}|{d},apad=whole_dur={d}[vo];", .{ VOICE_LUFS, VOICE_LRA, VOICE_OFFSET_MS, VOICE_OFFSET_MS, secs(REEL_MS) }); + std.debug.print("[2: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("[vo][mu]amix=inputs=2:normalize=0:duration=first,alimiter=limit=0.{d:0>3}[a]\n", .{millis(LIMIT_PERMILLE)}); +} + +// ---- tests ---- + +test "caption windows are the glass reel's: first, last" { + assert(shown_from_ms(WORD_START_MS[0]) == LAB_FIRST_SHOWN[0]); + assert(until_ms(0) == LAB_FIRST_SHOWN[1]); + assert(shown_from_ms(WORD_START_MS[20]) == LAB_LAST_SHOWN[0]); + assert(until_ms(20) == LAB_LAST_SHOWN[1]); +} + +test "every caption is gone before the CTA line comes" { + var k : usize = 0; + while (k < 21) { + assert(until_ms(k) <= cta_start_ms()); + k = k + 1; + } +} + +test "the music fade ends with the reel" { + assert(music_silent_at_ms() == REEL_MS); +} + +test "seconds and millis print a time exactly" { + assert(secs(10750) == 10); + assert(millis(10750) == 750); + assert(millis(400) == 400); + assert(secs(400) == 0); +} + +test "the CTA sits above the caption line and fades in inside the hold" { + assert(CTA_Y < CAPTION_Y); + assert(cta_lit_ms() == 2400); +} + +test "the widest caption still fits: 12 characters at 108 px" { + assert(font_size(12) == 108); + assert(font_size(6) == CAPTION_FONT_MAX); +}