teaser v9 animatic: 41 s story cut tight (hard cuts on the beat, 2-4 s shots, frame 0 moving, every shot opens on its subject, captions <= 6 words held >= 2 s, end card with npx storebound init held 4 s); render.ts --animatic (960x540 draft with sound, contact sheet, shot list, end-card layout checks); tests for the v9 timing rules

This commit is contained in:
CRTkafa 2026-10-14 00:21:09 +08:00
parent 7ac028421a
commit c608b9f63e
6 changed files with 326 additions and 207 deletions

View file

@ -1,10 +1,10 @@
// Teaser picture (v8, a story): every element is set from the time t alone (window.teaser.seek(t)), so frames render
// Teaser picture (v9, the story cut tight): every element is set from the time t alone (window.teaser.seek(t)), so frames render
// deterministically, in any order. 16:9 only (1920x1080). URL: ?t=12.5 holds a frame, ?play=1 previews in real time.
import { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_LINES, CONSOLES_SWAP,
DATA_SAFETY_CSV, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DURATION, END, END_LINE, END_URL,
FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_AT, GAME_SCREENS, GAME_STEP, MONTAGE,
DATA_SAFETY_CSV, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DURATION, END, END_CMD, END_URL,
FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_SCREENS, GAME_SPEED, MONTAGE,
PILE, PILE_TITLES, PRICE_ROWS, PRIVACY_ANSWERS, PRIVACY_STEPS, PRODUCT_LINES, PROMPT, PROMPT_AT, PROMPT_CPS, PROMPT_SENT,
REJECT, REJECT_GROW, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_STEP, scene, veil } from './script.js';
REJECT, REJECT_GROW, REJECT_SLAM, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_ROW, SHOTS_STEP, scene, veil } from './script.js';
const q = new URLSearchParams(location.search);
const W = FORMAT.w, H = FORMAT.h;
@ -23,6 +23,8 @@ const ecam = p => 1 - Math.pow(1 - clamp(p / 0.8), 4);
const esin = p => 0.5 - Math.cos(Math.PI * clamp(p)) / 2;
const lerp = (a, b, p) => a + (b - a) * p;
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Every shot's first frame already shows its subject: things that open a shot start this much before the cut. */
const LEAD = 0.15;
const set = (el, k, v) => { if (el.__s?.[k] !== v) { (el.__s ??= {})[k] = v; el.style[k] = v; } };
const html = (el, v) => { if (el.__h !== v) { el.__h = v; el.innerHTML = v; } };
const hl = s => esc(s).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>');
@ -44,11 +46,11 @@ function scenes(t) {
const from = i === 0 ? -1 : s.start - s.x / 2, until = next ? next.start + next.x / 2 : Infinity;
if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; }
set(el, 'visibility', 'visible');
// the first scene comes up from black; the others dissolve in over the previous one
set(el, 'opacity', String(i === 0 ? eio(prog(t, 0, 0.9)) : eio(prog(t, from, s.x))));
// hard cuts on the beat: the first frame is already the product, no fade from black
set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, s.x))));
const c = CAMERA[s.id];
const raw = (t - s.start) / (s.end - s.start);
const p = MONTAGE.includes(s.id) ? ecam(raw) : esin(raw);
const p = ecam(raw);
const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p);
set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`);
});
@ -67,20 +69,21 @@ function background(t) {
// ---------- captions: the story line (top) and the montage word (edge) ----------
const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband');
const CT = Object.fromEntries(captionTimes().map(c => [c.id, c]));
let capId = null;
let capId = null, edgeId = null;
function captions(t) {
const s = active(t), c = CAPTIONS.find(x => x.id === s.id);
let top = 0, edge = 0, rise = 0;
if (c) {
for (const c of CAPTIONS) {
if (t < c.at || t >= c.until) continue;
const ct = CT[c.id], [fin, fout] = CAP_FADE[c.pos];
const pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout);
const op = pin * (1 - pout);
rise = (1 - pin) * 14 - pout * 8;
if (capId !== c.id) {
capId = c.id;
if (c.pos === 'top') { capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); } else edgeEl.innerHTML = hl(c.text);
if (c.pos === 'top') {
top = op; rise = (1 - pin) * 14;
if (capId !== c.id) { capId = c.id; capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); }
} else {
edge = op;
if (edgeId !== c.id) { edgeId = c.id; edgeEl.innerHTML = hl(c.text); }
}
if (c.pos === 'top') top = op; else edge = op;
}
set(capEl, 'opacity', String(top)); set(capBand, 'opacity', String(top));
set(capEl, 'transform', `translateY(${rise.toFixed(1)}px)`);
@ -90,22 +93,24 @@ function captions(t) {
// ---------- 1 game: the game's own screens ----------
const gameEl = $('#game');
const phones = GAME_SCREENS.map((id, i) => {
const PHONE_GAP = 400, PHONES = [...GAME_SCREENS, ...GAME_SCREENS, ...GAME_SCREENS];
const phones = PHONES.map(id => {
const el = document.createElement('div');
el.className = 'phone';
Object.assign(el.style, { left: `${195 + i * 400}px`, backgroundImage: `url(out/assets/game/${id}.jpg)` });
el.style.backgroundImage = `url(out/assets/game/${id}.jpg)`;
gameEl.appendChild(el);
return el;
});
function game(t) {
const s = scene('game'); if (t > s.end + 1) return;
const r = t - s.start;
// the screens rush past from frame 0 (no fade-in): a conveyor, each on its own bob
phones.forEach((el, i) => {
const p = prog(r, GAME_AT + i * GAME_STEP, 0.9);
set(el, 'opacity', String(eoc(p)));
// a slow float, each phone on its own phase
const fy = Math.sin(r * 0.9 + i * 1.3) * 6;
set(el, 'transform', `translateY(${((1 - eoc(p)) * 40 + fy).toFixed(1)}px) rotate(${((i - 1.5) * 1.2).toFixed(2)}deg)`);
const x = 160 + i * PHONE_GAP - GAME_SPEED * r;
const fy = Math.sin(r * 3 + i * 1.3) * 10;
set(el, 'opacity', '1');
set(el, 'left', `${x.toFixed(1)}px`);
set(el, 'transform', `translateY(${fy.toFixed(1)}px) rotate(${(((i % 4) - 1.5) * 1.6).toFixed(2)}deg)`);
});
}
@ -153,14 +158,14 @@ function pile(t) {
const s = scene('pile'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => {
const p = prog(r, c.at, 0.32);
set(el, 'opacity', String(clamp(p * 2.5)));
const p = prog(r, c.at - LEAD, 0.2);
set(el, 'opacity', String(clamp(p * 3)));
const e = eoc(p);
set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`);
// the rows keep streaming: there is always more
if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`);
// older cards sink into the shadow of the new ones
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at, 0.32)), 0);
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at - LEAD, 0.2)), 0);
set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
});
}
@ -175,9 +180,12 @@ const findEl = $('#finding');
function reject(t) {
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
set(findEl, 'opacity', String(eoc(prog(r, -0.1, 0.45))));
const z = lerp(REJECT_GROW[0], REJECT_GROW[1], esin(prog(r, -0.1, s.end - s.start + 0.3)));
set(findEl, 'transform', `translate(-50%, -50%) scale(${z.toFixed(4)})`);
// the slam: in at once, overshooting scale down to 1 with a short shake; then a push onto the rule
const sp = prog(r, -LEAD, REJECT_SLAM.d + LEAD);
set(findEl, 'opacity', String(clamp(sp * 4)));
const z = lerp(REJECT_SLAM.from, 1, eoc(sp)) * lerp(REJECT_GROW[0], REJECT_GROW[1], ecam(prog(r, REJECT_SLAM.d, s.end - s.start - REJECT_SLAM.d)));
const shake = r < 0.45 && r > REJECT_SLAM.d ? Math.sin(r * 90) * 14 * (1 - (r - REJECT_SLAM.d) / (0.45 - REJECT_SLAM.d)) : 0;
set(findEl, 'transform', `translate(calc(-50% + ${shake.toFixed(1)}px), -50%) scale(${z.toFixed(4)})`);
}
// ---------- 4 ask: the request to the agent ----------
@ -185,7 +193,7 @@ const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), sentEl = $('#promp
function ask(t) {
const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
appear(promptEl, r, 0.15, 0.6, 16);
appear(promptEl, r, -0.05, 0.12, 0);
const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
const caret = r < PROMPT_SENT && (n < PROMPT.length || Math.floor(r * 2.2) % 2 === 0);
html(promptTxt, `${esc(PROMPT.slice(0, n))}${caret ? '<span class="caret"></span>' : ''}`);
@ -209,7 +217,7 @@ function montageTerm(id, sel, lns, lines) {
const s = scene(id); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
appear($(sel), r, -0.1, 0.2, 10);
lns.forEach((el, i) => appear(el, r, lines[i].at, 0.18, 6));
lns.forEach((el, i) => appear(el, r, lines[i].at - LEAD, 0.18, 6));
};
}
const builds = montageTerm('builds', '#buildterm', buildLns, BUILD_LINES);
@ -251,10 +259,10 @@ const shotTiles = [], shotLabels = [], langLabels = [];
function shots(t) {
const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
langLabels.forEach(el => appear(el, r, 0, 0.2, 0));
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT + col * SHOTS_STEP, 0.25)))));
langLabels.forEach(el => appear(el, r, -0.05, 0.1, 0));
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP, 0.2)))));
for (const { el, col, row } of shotTiles) {
const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.05, 0.28);
const p = prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP + row * SHOTS_ROW, 0.22);
set(el, 'opacity', String(eoc(p)));
set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
}
@ -272,7 +280,7 @@ function forms(t) {
const r = t - s.start;
appear($('#prop'), r, -0.1, 0.2, 10);
appear($('#form'), r, 0.0, 0.2, 10);
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.2, 6));
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT - LEAD + i * DECL_STEP : ASK_AT, 0.2, 6));
set(askGlow, 'opacity', String(eoc(prog(r, ASK_AT + 0.1, 0.3))));
formRows.forEach((el, i) => {
const p = prog(r, FORM_AT + i * FORM_STEP, 0.2);
@ -301,7 +309,7 @@ function consoles(t) {
const r = t - s.start;
const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1]));
set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out));
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.15, 5));
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at - LEAD, 0.15, 5));
set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2))));
}
@ -320,10 +328,10 @@ function scan(t) {
const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
appear($('#scanterm'), r, -0.1, 0.2, 10);
appear(scanLns[0], r, 0.05, 0.15, 4);
appear(scanLns[1], r, 0.25, 0.2, 6);
appear(scanLns[0], r, -LEAD, 0.12, 4);
appear(scanLns[1], r, 0.05, 0.15, 6);
fdEls.forEach((el, i) => {
appear(el, r, FINDINGS_AT + i * FINDINGS_STEP, 0.25, 8);
appear(el, r, FINDINGS_AT - LEAD + i * FINDINGS_STEP, 0.2, 8);
const f = prog(r, FIX_AT[i], 0.3);
set(el.querySelector('.tint.g'), 'opacity', String(eoc(f)));
set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f)));
@ -354,7 +362,7 @@ const desks = DESK_SCENES.map(id => {
});
function deskView(dk, r) {
const s = scene(dk.id), len = s.end - s.start, { WIN } = dk;
const p = dk.id === 'consoles' ? ecam(clamp(r / len)) : esin(clamp(r / len));
const p = ecam(clamp(r / len));
const { from, to } = dk.d.cam;
const S = WIN.s0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
const ox = clamp(WIN.w / 2 - fx * 1280 * S, WIN.w - 1280 * S, 0), oy = clamp(WIN.vh / 2 - fy * 800 * S, WIN.vh - 800 * S, 0);
@ -394,15 +402,29 @@ function deskScene(dk, t) {
}
const desktop = t => desks.forEach(dk => deskScene(dk, t));
// ---------- 9 end card ----------
const brand = $('#brand');
brand.querySelector('.line').textContent = END_LINE;
// ---------- 9 end card: the logo, the install line typed out, the site; the last thing on screen ----------
const brand = $('#brand'), endCmd = $('#brand .cmdl .t'), endCaret = $('#brand .cmdl .caret');
brand.querySelector('.url').textContent = END_URL;
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.line', END.lineAt], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.cmdl', END.cmdAt - 0.05], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
function end(t) {
const s = scene('end'); if (t < s.start - 1) return;
const r = t - s.start;
for (const [el, at] of brandEls) appear(el, r, at, END.d, 18);
for (const [el, at] of brandEls) {
const p = prog(r, at - LEAD, END.d);
set(el, 'opacity', String(clamp(p * 2)));
set(el, 'transform', `scale(${lerp(1.25, 1, eoc(p)).toFixed(4)})`);
}
const n = clamp(Math.floor((r - END.cmdAt) * END.cps), 0, END_CMD.length);
html(endCmd, esc(END_CMD.slice(0, n)));
// the caret: solid while typing, then a steady blink (two per second, on the beat) through the hold
set(endCaret, 'opacity', r < END.full || Math.floor((r - END.full) * 2) % 2 === 0 ? '1' : '0');
}
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
function layout(t) {
seek(t);
const sc = document.querySelector('#s-end'), el = $('#brand .cmdl'), b = el.getBoundingClientRect();
return { t, endVisible: getComputedStyle(sc).visibility === 'visible' && Number(sc.style.opacity) > 0.99, text: endCmd.textContent,
opacity: Number(el.style.opacity), box: { x: b.x, y: b.y, w: b.width, h: b.height }, fontPx: parseFloat(getComputedStyle(el).fontSize) };
}
function seek(t) {
@ -440,7 +462,7 @@ const ready = (async () => {
for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode));
seek(0);
})();
window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes };
window.teaser = { ready, seek, layout, DURATION, FPS, W, H, captionTimes };
if (q.has('t')) ready.then(() => seek(Number(q.get('t'))));
if (q.has('play')) ready.then(() => {