teaser: the reject shot redone (7-11 s, 4 s): the common rejections storebound catches as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT from tests/fixtures/scan/teaser; ITMS-91064 from the new teaser-manifest fixture), guideline + one plain line each, then our own REJECTED stamp hits the stack on the beat at 9.5 s, no shake (one 4 px 1-frame jolt), a 3% push, the picture darkens into the cut; caption 'Rejected. Again.'; later shots +1.5 s (42.5 s, every cut on a beat, music follows the scenes, a thud per card and a hit on the stamp); render.ts --animatic --range a,b and --sheet a,b,fps, and the reject cards' boxes per frame in animatic-checks.json; tests: real finding lines, beat timing, and every card inside 6% safe margins at every frame
This commit is contained in:
parent
511716bfd5
commit
20a764b3ee
11 changed files with 205 additions and 86 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# storebound teaser (v9, 41 s, 16:9)
|
||||
# storebound teaser (v9, 42.5 s, 16:9)
|
||||
|
||||
The v8 story cut tight: the game is done, the stores pile up work, one line gets it rejected, so your AI does it, and
|
||||
you press Submit. Editing rules: frame 0 already moves and shows the product (no black, no calm intro); nothing holds
|
||||
|
|
@ -16,17 +16,17 @@ Chrome and encoded with ffmpeg; the music and sound design are synthesized in th
|
|||
|---|---|---|---|
|
||||
| 0-3 | the demo game's own screens rush past, a push in | Your game is done. | drives from frame 0: kick, bass, arpeggio |
|
||||
| 3-7 | real store work lands one card per beat: App Privacy answers and steps, the Data safety CSV (768 rows), screenshot sizes, price sheets | Then come the stores. | tension: B minor pulse, kicks, a thud per card |
|
||||
| 7-9.5 | the real scan finding (BLOCKER 5.1.1(v), Planner/LoginView.swift:11) slams in, a push onto it, the picture darkens | One line. Rejected. | a low boom, the clock keeps ticking |
|
||||
| 9.5-12 | one request typed to the AI agent, sent on the beat | So my AI did it. | a short drop: an impact, half-time beat, key clicks, a swell |
|
||||
| 12-15 | `apple build --dry`, `play build --dry`: build 52, versionCode 52 | Builds. | the drop |
|
||||
| 15-18 | the demo game's renders: 7 store sizes x 4 languages fill in | Screenshots, every size. | + hats |
|
||||
| 18-21 | `play products --dry`, `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps |
|
||||
| 21-24 | the scan's declarations proposal, the Data safety rows ticking, 768 answers | Forms. | + sixteenths |
|
||||
| 24-27 | `console plan apple-app-privacy`, then the desktop app's plan panel, the owner's Publish ringed | Consoles. | + lead |
|
||||
| 27-31 | `storebound scan`: five findings turn green one per beat, the clean re-scan | Nothing left to reject. | a chime per fix |
|
||||
| 31-33 | the desktop app's Send for review, the camera pushes onto Submit, the pointer arrives | You press Submit. | the music stops |
|
||||
| 33-36 | the click, then the app's own "Waiting for review" | (same line) | the resolve |
|
||||
| 36-41 | the logo, `npx storebound init` typed out, storebound.dev; holds 4 s | (none) | a confident kick and bass on D, the chord; fades over the last 1 s |
|
||||
| 7-11 | the common rejections storebound catches, as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT, ITMS-91064 privacy manifest), every card inside 6% safe margins; at 9.5 s our own REJECTED stamp hits the stack; the picture darkens into the cut | Rejected. Again. | a low boom, a thud per card, a hit on the stamp |
|
||||
| 11-13.5 | one request typed to the AI agent, sent on the beat | So my AI did it. | a short drop: an impact, half-time beat, key clicks, a swell |
|
||||
| 13.5-16.5 | `apple build --dry`, `play build --dry`: build 52, versionCode 52 | Builds. | the drop |
|
||||
| 16.5-19.5 | the demo game's renders: 7 store sizes x 4 languages fill in | Screenshots, every size. | + hats |
|
||||
| 19.5-22.5 | `play products --dry`, `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps |
|
||||
| 22.5-25.5 | the scan's declarations proposal, the Data safety rows ticking, 768 answers | Forms. | + sixteenths |
|
||||
| 25.5-28.5 | `console plan apple-app-privacy`, then the desktop app's plan panel, the owner's Publish ringed | Consoles. | + lead |
|
||||
| 28.5-32.5 | `storebound scan`: five findings turn green one per beat, the clean re-scan | Nothing left to reject. | a chime per fix |
|
||||
| 32.5-34.5 | the desktop app's Send for review, the camera pushes onto Submit, the pointer arrives | You press Submit. | the music stops |
|
||||
| 34.5-37.5 | the click, then the app's own "Waiting for review" | (same line) | the resolve |
|
||||
| 37.5-42.5 | the logo, `npx storebound init` typed out, storebound.dev; holds 4 s | (none) | a confident kick and bass on D, the chord; fades over the last 1 s |
|
||||
|
||||
|
||||
The demo game has 4 languages (en, tr, ja, ar), so the screenshot beat says "every size", not a language count.
|
||||
|
|
@ -52,7 +52,7 @@ clock prefix and the "(0s)" timer left out), in a fresh `storebound init` folder
|
|||
- `storebound apple build --dry` and `storebound play build --dry` in a minimal Capacitor project.
|
||||
- `storebound play products --dry` and `catalog diff`, with the stores answered by
|
||||
`tests/helpers/teaser-store-mock.ts` (preloaded through a `bunfig.toml` in the folder; nothing reaches the network).
|
||||
- `storebound scan` of `tests/fixtures/scan/teaser` (the 5.1.1(v) finding, all five findings, the declarations
|
||||
- `storebound scan` of `tests/fixtures/scan/teaser` (the reject cards' 2.1(a), 5.1.1(v) and 5.1.2(i) findings; the ITMS-91064 card from `teaser-manifest`, all five findings, the declarations
|
||||
proposal) and of `teaser-fixed` after the fixes.
|
||||
|
||||
The test also checks the timing (every cut on a half-second beat, scenes 3-6 s, the montage at 19-37 s speeding up with
|
||||
|
|
@ -72,6 +72,7 @@ bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, soun
|
|||
bun media/teaser/render.ts --scale 2 > media/teaser/out/render-4k.log 2>&1 & # 3840x2160 master for X (about 40 Mbps, AAC 320k): about 45 min
|
||||
bun media/teaser/render.ts --desktop # capture the desktop app frames again (after a UI change)
|
||||
bun media/teaser/render.ts --animatic ../storebound-dist/teaser-v9-animatic # draft.mp4 (960x540), contact-sheet.png, shots.md: for the owner's OK
|
||||
bun media/teaser/render.ts --animatic <dir> --range 5,14 --sheet 6,12,4 # one region only, contact sheet at 4 frames/s; writes the reject cards' boxes to out/animatic-checks.json (safe-margin test)
|
||||
bun media/teaser/render.ts --stills 2.5,13.5,38 # PNG stills in out/stills for a quick layout check
|
||||
bun media/teaser/render.ts --sound # the music only (out/music.wav)
|
||||
bun media/teaser/render.ts --serve --port 8750 # live preview: /index.html?play=1 (or ?t=12.5)
|
||||
|
|
|
|||
|
|
@ -83,14 +83,17 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
.pcard .tile { position: absolute; border-radius: 6px; background: center / cover no-repeat; box-shadow: 0 6px 16px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.08); }
|
||||
|
||||
/* ---------- 3 reject: the finding ---------- */
|
||||
#finding { left: 50%; top: 52%; width: max-content; padding: 34px 44px 38px; border-radius: 26px; background: #1a0d12;
|
||||
border: 3px solid rgba(255,90,95,.65); box-shadow: 0 0 0 10px rgba(229,72,77,.12), 0 0 160px rgba(229,72,77,.35), 0 50px 120px rgba(0,0,0,.7);
|
||||
font-family: var(--mono); opacity: 0; transform-origin: 30% 50%; }
|
||||
#finding .l1 { display: flex; align-items: center; gap: 18px; white-space: nowrap; font-size: 31px; font-weight: 700; color: #ffd6d7; }
|
||||
#finding .sev { color: #fff; background: var(--red-2); border-radius: 9px; padding: 3px 14px; font-weight: 800; }
|
||||
#finding .gl { color: #fff; font-weight: 800; }
|
||||
#finding .l2 { margin-top: 18px; white-space: nowrap; font-size: 26px; color: var(--ink-2); }
|
||||
#finding .l2 .loc { font-weight: 700; }
|
||||
#rejects { left: 50%; top: 61%; width: 1560px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px 32px; transform-origin: 50% 50%; }
|
||||
.rj { position: relative; padding: 26px 32px 28px; border-radius: 24px; background: #1a0d12; border: 3px solid rgba(255,90,95,.6);
|
||||
box-shadow: 0 0 0 8px rgba(229,72,77,.1), 0 30px 80px rgba(0,0,0,.6); opacity: 0; transform-origin: 50% 50%; }
|
||||
.rj .rule { display: flex; align-items: center; gap: 14px; font-family: var(--mono); font-size: 26px; font-weight: 800; color: #fff; }
|
||||
.rj .sev { color: #fff; background: var(--red-2); border-radius: 8px; padding: 2px 11px; font-size: 19px; font-weight: 800; }
|
||||
.rj .title { margin-top: 12px; font-family: var(--display); font-size: 48px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.08; color: #fff; white-space: nowrap; }
|
||||
.rj .real { margin-top: 16px; padding-top: 12px; border-top: 2px solid rgba(255,90,95,.22); font-family: var(--mono); font-size: 16px; line-height: 1.45; color: #d9a9ab; overflow-wrap: anywhere; }
|
||||
/* the REJECTED stamp: our own graphic, a rotated rounded rectangle with bold type, over the stack */
|
||||
#stamp { left: 50%; top: 61%; padding: 12px 44px 14px; border: 12px solid var(--red-2); border-radius: 28px; color: var(--red-2);
|
||||
background: rgba(20,8,12,.82); font-family: var(--display); font-size: 140px; font-weight: 900; letter-spacing: 0.06em; line-height: 1;
|
||||
opacity: 0; white-space: nowrap; box-shadow: 0 0 0 4px rgba(229,72,77,.2) inset, 0 30px 90px rgba(0,0,0,.6); }
|
||||
|
||||
/* ---------- 4 ask: the request ---------- */
|
||||
#prompt { left: 260px; top: 470px; width: 1400px; padding: 30px 38px; display: flex; flex-direction: column; gap: 16px;
|
||||
|
|
@ -182,7 +185,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
<div class="scene" id="s-game"><div class="cam" id="game"></div></div>
|
||||
<div class="scene" id="s-pile"><div class="cam" id="pile"></div></div>
|
||||
<div class="scene" id="s-reject"><div class="cam">
|
||||
<div id="finding" class="abs"><div class="l1"></div><div class="l2"></div></div>
|
||||
<div id="rejects" class="abs"></div><div id="stamp" class="abs">REJECTED</div>
|
||||
</div></div>
|
||||
<div class="scene" id="s-ask"><div class="cam">
|
||||
<div id="prompt" class="abs"><span class="who"><span class="pi i-term"></span>You, to your AI agent</span><span class="txt"></span><span class="sent"><span class="pi i-send"></span></span></div>
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ export async function renderMusic() {
|
|||
if (x.kind === 'tiny') tick(x.t, 0.02, 2800);
|
||||
else if (x.kind === 'thud') thud(x.t, 0.12 + x.i * 0.015);
|
||||
else if (x.kind === 'low') tone(x.t, 38, 0.05, 0.9, 'triangle');
|
||||
else if (x.kind === 'stamp') { kick(x.t, 0.55, master); thud(x.t, 0.3); boom(x.t, 0.35); tick(x.t, 0.08, 1800); }
|
||||
else if (x.kind === 'send') { tone(x.t, 81, 0.03, 0.3); tone(x.t + 0.1, 86, 0.025, 0.6); }
|
||||
else if (x.kind === 'good') { const m = [74, 76, 78, 81, 83][x.step] ?? 86; tone(x.t, m + 12, 0.045, 0.5, 'sine', -0.3 + 0.15 * x.step); tone(x.t, m + 24, 0.012, 0.3); }
|
||||
else if (x.kind === 'clean') { tone(x.t, 86, 0.04, 0.8); tone(x.t + 0.08, 90, 0.03, 0.9); tone(x.t + 0.16, 93, 0.025, 1.0); }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
// bun media/teaser/render.ts --sound the music and sound only (out/music.wav)
|
||||
// bun media/teaser/render.ts --desktop capture the desktop app frames again first (desktop.ts)
|
||||
// bun media/teaser/render.ts --animatic <dir> the animatic for the owner's OK before any final render: draft.mp4
|
||||
// (960x540, 30 fps, quick encode, with sound), contact-sheet.png
|
||||
// (960x540, 30 fps, quick encode, with sound), contact-sheet.png;
|
||||
// --range 5,14 renders only that region, --sheet 6,12,4 sets the sheet's span and rate
|
||||
// (one frame per second, timestamped), shots.md, checks.json
|
||||
// bun media/teaser/render.ts --scale 2 the 3840x2160 master for 4K uploads: the same page at device scale 2
|
||||
// (text and terminals rendered natively), the desktop frames captured
|
||||
|
|
@ -125,7 +126,8 @@ async function main(): Promise<void> {
|
|||
mkdirSync(join(OUT, 'stills'), { recursive: true });
|
||||
for (const t of stills.split(',').map(Number)) await Bun.write(join(OUT, 'stills', `${t.toFixed(2)}.png`), await shot(t));
|
||||
log(`${stills.split(',').length} stills in out/stills`);
|
||||
if (errors.length) throw new Error(`page errors:\n ${errors.join('\n ')}`);
|
||||
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
|
||||
if (errors.length) throw new Error(`page errors:\n ${errors.join('\n ')}`);
|
||||
await page.close();
|
||||
return;
|
||||
}
|
||||
|
|
@ -149,7 +151,8 @@ async function main(): Promise<void> {
|
|||
}
|
||||
await enc.stdin.end();
|
||||
if (await enc.exited !== 0) throw new Error('ffmpeg encode failed');
|
||||
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
|
||||
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
ff('-ss', String(DURATION - 0.5), '-i', silent, '-frames:v', '1', '-q:v', '3', join(OUT, `poster-${OUT_W}x${OUT_H}.jpg`));
|
||||
log(`${silent} (${(statSync(silent).size / 1e6).toFixed(1)} MB)`);
|
||||
await page.close();
|
||||
|
|
@ -216,9 +219,11 @@ async function animatic(base: string, dir: string): Promise<void> {
|
|||
const silent = join(dir, 'draft-silent.mp4');
|
||||
const enc = Bun.spawn(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-',
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-g', String(FPS), silent], { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' });
|
||||
const frames = Math.round(DURATION * FPS);
|
||||
// --range a,b: only that region (a re-cut of one shot); --sheet a,b,fps: the contact sheet's span and rate
|
||||
const [r0, r1] = (opt('range') ?? `0,${DURATION}`).split(',').map(Number) as [number, number];
|
||||
const frames = Math.round((r1 - r0) * FPS);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await page.evaluate(`teaser.seek(${i / FPS})`);
|
||||
await page.evaluate(`teaser.seek(${r0 + i / FPS})`);
|
||||
enc.stdin.write(await page.screenshot({ type: 'png' }));
|
||||
if (i % 150 === 0) log(`draft frame ${i + 1}/${frames}`);
|
||||
}
|
||||
|
|
@ -227,21 +232,25 @@ async function animatic(base: string, dir: string): Promise<void> {
|
|||
// the end card's install line over the last 4 s, every 0.25 s
|
||||
const layout: unknown[] = [];
|
||||
for (let t = DURATION - 4; t < DURATION - 1e-6; t += 0.25) layout.push(await page.evaluate(`teaser.layout(${t})`));
|
||||
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
|
||||
const rj = S.scene('reject'), rejectLayout: unknown[] = [];
|
||||
for (let i = 0; i < Math.round((rj.end - rj.start) * FPS); i++) rejectLayout.push(await page.evaluate(`teaser.rejectLayout(${rj.start + i / FPS})`));
|
||||
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
// sound
|
||||
const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string };
|
||||
const raw = join(dir, 'music-raw.wav');
|
||||
await Bun.write(raw, Buffer.from(wav.base64, 'base64'));
|
||||
ff('-i', silent, '-i', raw, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-16:TP=-1.5', '-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-shortest', '-movflags', '+faststart', join(dir, 'draft.mp4'));
|
||||
ff('-i', silent, '-ss', String(r0), '-t', String(r1 - r0), '-i', raw, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-16:TP=-1.5', '-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-shortest', '-movflags', '+faststart', join(dir, 'draft.mp4'));
|
||||
rmSync(silent); rmSync(raw);
|
||||
// contact sheet: the draft's frame at every whole second, timestamped
|
||||
const tmp = join(dir, 'sheet-frames');
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
const secs = Array.from({ length: Math.ceil(DURATION) }, (_, k) => k);
|
||||
for (const k of secs) ff('-ss', String(Math.min(k, DURATION - 1 / FPS)), '-i', join(dir, 'draft.mp4'), '-frames:v', '1', '-vf', 'scale=320:-2', '-q:v', '3', join(tmp, `${k}.jpg`));
|
||||
const [s0, s1, sfps] = (opt('sheet') ?? `${r0},${r1},1`).split(',').map(Number) as [number, number, number];
|
||||
const secs = Array.from({ length: Math.round((s1 - s0) * sfps) }, (_, k) => s0 + k / sfps).filter(k => k >= r0 && k < r1);
|
||||
for (const k of secs) ff('-ss', String(Math.min(k - r0, r1 - r0 - 1 / FPS)), '-i', join(dir, 'draft.mp4'), '-frames:v', '1', '-vf', 'scale=320:-2', '-q:v', '3', join(tmp, `${k}.jpg`));
|
||||
const shotAt = (t: number) => S.SCENES.find((s: any) => t >= s.start && t < s.end)?.id ?? 'end';
|
||||
const cols = 7;
|
||||
const cells = secs.map(k => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k}s</b> ${shotAt(k)}</figcaption></figure>`).join('');
|
||||
const cells = secs.map(k => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k.toFixed(2)}s</b> ${shotAt(k)}</figcaption></figure>`).join('');
|
||||
const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 });
|
||||
await sheet.setContent(`<style>body{margin:0;padding:10px;background:#111;font:600 15px system-ui;color:#ddd;display:grid;grid-template-columns:repeat(${cols},320px);gap:10px}figure{margin:0}img{display:block;width:320px;height:180px}figcaption{padding:4px 2px}b{color:#fff}</style>${cells}`);
|
||||
await sheet.screenshot({ path: join(dir, 'contact-sheet.png'), fullPage: true });
|
||||
|
|
@ -254,7 +263,7 @@ async function animatic(base: string, dir: string): Promise<void> {
|
|||
return `${i + 1}. ${s.start.toFixed(1)}-${s.end.toFixed(1)} s (${(s.end - s.start).toFixed(1)} s) **${s.id}**: ${S.SHOT_NOTES[s.id]}${c.length ? ` · caption "${c.map((x: any) => x.text).join('", "')}" (${c.map((x: any) => `${x.from.toFixed(2)}-${x.to.toFixed(2)} s`).join(', ')})` : ''}`;
|
||||
}), ''];
|
||||
writeFileSync(join(dir, 'shots.md'), md.join('\n'));
|
||||
writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, endLayout: layout }, null, 2));
|
||||
writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, fps: FPS, frame: FORMAT, endLayout: layout, rejectLayout }, null, 2));
|
||||
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
// Teaser timeline (v9, the story cut tight): the single source of truth for the picture (teaser.js) and the sound
|
||||
// (music.js). 41 s, 16:9, 120 BPM: every cut lands on a beat (a whole half second), every shot does one thing in
|
||||
// (music.js). 42.5 s, 16:9, 120 BPM: every cut lands on a beat (a whole half second), every shot does one thing in
|
||||
// 2-4 s and cuts when that thing completes. Frame 0 already moves; nothing holds still longer than 0.8 s; the last
|
||||
// 4 s are the end card with `npx storebound init`.
|
||||
//
|
||||
// game 0-3 cold open: the demo game's real screens rush past, a push in "Your game is done."
|
||||
// pile 3-7 a wall of real store work lands, one card per beat "Then come the stores."
|
||||
// reject 7-9.5 the real scan finding 5.1.1(v) slams in, the picture darkens "One line. Rejected."
|
||||
// ask 9.5-12 one request typed to the AI agent, sent on the beat "So my AI did it."
|
||||
// builds..consoles 12-27 the montage, 3 s each, one word at the edge, each result lands: Builds. /
|
||||
// reject 7-11 real scan findings land one per beat, then the REJECTED stamp "Rejected. Again."
|
||||
// ask 11-13.5 one request typed to the AI agent, sent on the beat "So my AI did it."
|
||||
// builds..consoles 13.5-28.5 the montage, 3 s each, one word at the edge, each result lands: Builds. /
|
||||
// Screenshots, every size. / Products, 175 countries. / Forms. / Consoles.
|
||||
// scan 27-31 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
|
||||
// button 31-33 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
|
||||
// waiting 33-36 the click, then the app's own "Waiting for review" (same line)
|
||||
// end 36-41 the logo, `npx storebound init` typed out, storebound.dev (4 s hold)
|
||||
// scan 28.5-32.5 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
|
||||
// button 32.5-34.5 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
|
||||
// waiting 34.5-37.5 the click, then the app's own "Waiting for review" (same line)
|
||||
// end 37.5-42.5 the logo, `npx storebound init` typed out, storebound.dev (4 s hold)
|
||||
//
|
||||
// Every CLI line shown is real output; tests/teaser.test.ts runs each command and checks every line:
|
||||
// pile `storebound console plan apple-app-privacy` (answer sheet, steps), `storebound play data-safety --dry`
|
||||
|
|
@ -33,7 +33,7 @@ export const FORMAT = { w: 1920, h: 1080 };
|
|||
|
||||
/** Shots: id, length (s), the transition into it (s; 0 = a hard cut on the beat). */
|
||||
const LENGTHS = [
|
||||
['game', 3, 0], ['pile', 4, 0], ['reject', 2.5, 0], ['ask', 2.5, 0],
|
||||
['game', 3, 0], ['pile', 4, 0], ['reject', 4, 0], ['ask', 2.5, 0],
|
||||
['builds', 3, 0], ['shots', 3, 0], ['products', 3, 0], ['forms', 3, 0], ['consoles', 3, 0],
|
||||
['scan', 4, 0], ['button', 2, 0], ['waiting', 3, 0], ['end', 5, 0],
|
||||
];
|
||||
|
|
@ -46,7 +46,7 @@ export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
|
|||
export const SHOT_NOTES = {
|
||||
game: 'the demo game\'s four real screens rush past, push in; cut as the push lands',
|
||||
pile: 'App Privacy, Data safety CSV, screenshot sizes, prices: one real card per beat; cut when the wall is full',
|
||||
reject: 'the real BLOCKER 5.1.1(v) finding slams in, push onto it, the picture darkens; cut when it fills the frame',
|
||||
reject: 'the common rejections storebound catches, as real scan finding cards (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT, ITMS-91064 privacy manifest), one per beat, all inside the safe margins; the REJECTED stamp hits the stack on the beat at 2.5 s; the picture darkens into the cut',
|
||||
ask: 'one request typed to the AI agent; cut on the send',
|
||||
builds: '`apple build --dry`, `play build --dry`: build 52 / versionCode 52; cut on "build planned"',
|
||||
shots: 'the demo game\'s renders: 7 store sizes x 4 languages fill in column by column; cut when the grid is full',
|
||||
|
|
@ -81,15 +81,15 @@ export const CAMERA = {
|
|||
export const CAPTIONS = [
|
||||
{ id: 'game', pos: 'top', text: 'Your game is *done.*', at: 0.1, until: 2.95 },
|
||||
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 3.05, until: 6.95 },
|
||||
{ id: 'reject', pos: 'top', text: 'One line. *Rejected.*', at: 7.05, until: 9.45, hl: 'red' },
|
||||
{ id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 9.55, until: 11.95 },
|
||||
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 12.05, until: 14.95 },
|
||||
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 15.05, until: 17.95 },
|
||||
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 18.05, until: 20.95 },
|
||||
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 21.05, until: 23.95 },
|
||||
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 24.05, until: 26.95 },
|
||||
{ id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 28.6, until: 30.95, hl: 'green' },
|
||||
{ id: 'button', pos: 'top', text: 'You press *Submit.*', at: 31.1, until: 35.95 },
|
||||
{ id: 'reject', pos: 'top', text: '*Rejected.* Again.', at: 7.05, until: 10.95, hl: 'red' },
|
||||
{ id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 11.05, until: 13.45 },
|
||||
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 13.55, until: 16.45 },
|
||||
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 16.55, until: 19.45 },
|
||||
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 19.55, until: 22.45 },
|
||||
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 22.55, until: 25.45 },
|
||||
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 25.55, until: 28.45 },
|
||||
{ id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 30.1, until: 32.45, hl: 'green' },
|
||||
{ id: 'button', pos: 'top', text: 'You press *Submit.*', at: 32.6, until: 37.45 },
|
||||
];
|
||||
/** Fades (s): quick, a cut is a cut. */
|
||||
export const CAP_FADE = { top: [0.12, 0.08], edge: [0.1, 0.08] };
|
||||
|
|
@ -105,8 +105,8 @@ export function captionTimes() {
|
|||
* first beat of the montage. */
|
||||
export function veil(t) {
|
||||
const r = scene('reject'), a = scene('ask');
|
||||
if (t < r.start + 0.15) return 0;
|
||||
if (t < r.end) return 0.7 * Math.min(1, (t - r.start - 0.15) / 1.6);
|
||||
if (t < r.start + REJECT_DARK) return 0;
|
||||
if (t < r.end) return 0.7 * Math.min(1, (t - r.start - REJECT_DARK) / (r.end - r.start - REJECT_DARK - 0.1));
|
||||
if (t < a.end) return 0.6;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -186,14 +186,29 @@ export const PILE = [
|
|||
/** The card headers (what each card is; no claims). */
|
||||
export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' };
|
||||
|
||||
// ---------- 3 reject: one finding grows to fill the frame ----------
|
||||
/** The scan's own lines for the account-deletion finding (tests/fixtures/scan/teaser). */
|
||||
export const REJECT = {
|
||||
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
|
||||
at: 'at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }',
|
||||
};
|
||||
/** The slam (scale from -> 1 in `slam` s), then a push onto the rule number. */
|
||||
export const REJECT_GROW = [1.0, 1.28], REJECT_SLAM = { from: 1.7, d: 0.16 };
|
||||
// ---------- 3 reject: the common rejections storebound catches, one card per beat, then the stamp ----------
|
||||
/** Real `storebound scan` findings: guideline + one plain line, the scan's own header below in small type.
|
||||
* head = the finding line as printed (tests/teaser.test.ts checks it in the fixture's scan output). */
|
||||
export const REJECTS = [
|
||||
{ fixture: 'teaser', rule: 'Guideline 2.1(a)', title: 'No demo account for the reviewer',
|
||||
head: 'BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review' },
|
||||
{ fixture: 'teaser', rule: 'Guideline 5.1.1(v)', title: 'No way to delete the account',
|
||||
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found' },
|
||||
{ fixture: 'teaser', rule: 'Guideline 5.1.2(i)', title: 'Tracking without asking (ATT)',
|
||||
head: 'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)' },
|
||||
{ fixture: 'teaser-manifest', rule: 'ITMS-91064', title: 'Privacy manifest refused at upload',
|
||||
head: 'BLOCKER Privacy manifest (ITMS-91064) · App Store · Planner/PrivacyInfo.xcprivacy: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (Apple refuses the build: ITMS-91064)' },
|
||||
];
|
||||
/** The cards land one per beat (s into the shot; scale `land.from` -> 1 in `land.d` s, no shake). The REJECTED stamp
|
||||
* hits on the beat at STAMP.at (scale from -> 1 in d s, one 1-frame jolt of `jolt` px); a slow push; the picture
|
||||
* darkens from REJECT_DARK into the cut. */
|
||||
export const REJECT_STEP = 0.5;
|
||||
export const REJECT_LAND = { from: 1.06, d: 0.16 };
|
||||
export const REJECT_PUSH = [1.0, 1.03];
|
||||
export const STAMP = { at: 2.5, d: 0.12, from: 2.4, rot: -8, jolt: 4 };
|
||||
export const REJECT_DARK = 3.2;
|
||||
/** Safe margins: every card stays this far (fraction of width / height) inside the frame at every frame. */
|
||||
export const SAFE = 0.06;
|
||||
|
||||
// ---------- 4 ask: the request to the agent ----------
|
||||
export const PROMPT = 'Get this game ready for review on both stores.';
|
||||
|
|
@ -326,7 +341,8 @@ export function ticks() {
|
|||
const at = id => scene(id).start;
|
||||
return [
|
||||
...PILE.map((c, i) => ({ t: at('pile') + c.at, kind: 'thud', i })),
|
||||
{ t: at('reject') + 0.05, kind: 'low' },
|
||||
...REJECTS.map((_, i) => ({ t: at('reject') + i * REJECT_STEP, kind: 'thud', i })),
|
||||
{ t: at('reject') + STAMP.at, kind: 'stamp' },
|
||||
{ t: at('ask') + PROMPT_SENT, kind: 'send' },
|
||||
...BUILD_LINES.filter(l => !l.cmd).map(l => ({ t: at('builds') + l.at, kind: 'tiny' })),
|
||||
...PRODUCT_LINES.filter(l => !l.cmd).map(l => ({ t: at('products') + l.at, kind: 'tiny' })),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_
|
|||
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, 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';
|
||||
REJECT_DARK, REJECT_LAND, REJECT_PUSH, REJECT_STEP, REJECTS, STAMP, 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;
|
||||
|
|
@ -170,22 +170,29 @@ function pile(t) {
|
|||
});
|
||||
}
|
||||
|
||||
// ---------- 3 reject: one finding grows to fill the frame ----------
|
||||
const findEl = $('#finding');
|
||||
{
|
||||
const m = REJECT.head.match(/^(BLOCKER) (\S+) · App Store · (.+)$/);
|
||||
findEl.querySelector('.l1').innerHTML = `<span class="sev">${m[1]}</span><span class="gl">${esc(m[2])}</span><span>· App Store ·</span><span>${esc(m[3])}</span>`;
|
||||
findEl.querySelector('.l2').innerHTML = esc(REJECT.at).replace(/^(at \S+)/, '<span class="loc">$1</span>');
|
||||
}
|
||||
// ---------- 3 reject: real scan findings land one per beat, then the REJECTED stamp ----------
|
||||
const rejEl = $('#rejects'), stampEl = $('#stamp');
|
||||
rejEl.innerHTML = REJECTS.map(f => {
|
||||
const m = f.head.match(/^(\S+)\s+.*? · App Store · (.+)$/);
|
||||
return `<div class="rj"><div class="rule"><span class="sev">${m[1]}</span><span>${esc(f.rule)}</span></div><div class="title">${esc(f.title)}</div><div class="real">${esc(m[2])}</div></div>`;
|
||||
}).join('');
|
||||
const rjEls = [...rejEl.children];
|
||||
function reject(t) {
|
||||
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
// 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)})`);
|
||||
const r = t - s.start, len = s.end - s.start;
|
||||
const jolt = r >= STAMP.at && r < STAMP.at + 1 / 30 ? STAMP.jolt : 0;
|
||||
const z = lerp(REJECT_PUSH[0], REJECT_PUSH[1], esin(prog(r, 0, len)));
|
||||
set(rejEl, 'transform', `translate(calc(-50% + ${jolt}px), calc(-50% + ${jolt / 2}px)) scale(${z.toFixed(4)})`);
|
||||
set(rejEl, 'filter', `brightness(${(1 - 0.45 * eio(prog(r, REJECT_DARK, len - REJECT_DARK))).toFixed(3)})`);
|
||||
rjEls.forEach((el, i) => {
|
||||
const lp = prog(r, i * REJECT_STEP - (i ? 0 : LEAD), REJECT_LAND.d + (i ? 0 : LEAD));
|
||||
set(el, 'opacity', String(clamp(lp * 4)));
|
||||
set(el, 'transform', `scale(${lerp(REJECT_LAND.from, 1, eoc(lp)).toFixed(4)})`);
|
||||
});
|
||||
const sp = prog(r, STAMP.at - STAMP.d, STAMP.d);
|
||||
set(stampEl, 'opacity', String(r < STAMP.at - STAMP.d ? 0 : clamp(sp * 3)));
|
||||
set(stampEl, 'filter', rejEl.style.filter);
|
||||
set(stampEl, 'transform', `translate(calc(-50% + ${jolt}px), -50%) rotate(${STAMP.rot}deg) scale(${lerp(STAMP.from, 1, eoc(sp)).toFixed(4)})`);
|
||||
}
|
||||
|
||||
// ---------- 4 ask: the request to the agent ----------
|
||||
|
|
@ -419,6 +426,13 @@ function end(t) {
|
|||
// 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');
|
||||
}
|
||||
/** The reject cards' boxes (and the stamp's) as drawn at time t, for the safe-margin check. */
|
||||
function rejectLayout(t) {
|
||||
seek(t);
|
||||
const k = W / stage.getBoundingClientRect().width;
|
||||
const box = el => { const b = el.getBoundingClientRect(); return { x: b.x * k, y: b.y * k, w: b.width * k, h: b.height * k }; };
|
||||
return { t, cards: rjEls.filter(el => Number(el.style.opacity) > 0).map(box), stamp: Number(stampEl.style.opacity) > 0 ? box(stampEl) : null };
|
||||
}
|
||||
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
|
||||
function layout(t) {
|
||||
seek(t);
|
||||
|
|
@ -462,7 +476,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, layout, DURATION, FPS, W, H, captionTimes };
|
||||
window.teaser = { ready, seek, layout, rejectLayout, DURATION, FPS, W, H, captionTimes };
|
||||
|
||||
if (q.has('t')) ready.then(() => seek(Number(q.get('t'))));
|
||||
if (q.has('play')) ready.then(() => {
|
||||
|
|
|
|||
13
tests/fixtures/scan/teaser-manifest/Planner.xcodeproj/project.pbxproj
vendored
Normal file
13
tests/fixtures/scan/teaser-manifest/Planner.xcodeproj/project.pbxproj
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
objects = {
|
||||
C1B2C3D4E5F6000000000002 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
INFOPLIST_FILE = Planner/Info.plist;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.planner;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
10
tests/fixtures/scan/teaser-manifest/Planner/Info.plist
vendored
Normal file
10
tests/fixtures/scan/teaser-manifest/Planner/Info.plist
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Planner</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
10
tests/fixtures/scan/teaser-manifest/Planner/PrivacyInfo.xcprivacy
vendored
Normal file
10
tests/fixtures/scan/teaser-manifest/Planner/PrivacyInfo.xcprivacy
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<true/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
4
tests/fixtures/scan/teaser-manifest/README.md
vendored
Normal file
4
tests/fixtures/scan/teaser-manifest/README.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# teaser-manifest
|
||||
|
||||
The teaser reject shot's ITMS-91064 card: an app privacy manifest with NSPrivacyTracking true and an empty
|
||||
NSPrivacyTrackingDomains (tests/teaser.test.ts checks the line).
|
||||
|
|
@ -19,14 +19,16 @@ const MOCK = join(import.meta.dir, 'helpers', 'teaser-store-mock.ts');
|
|||
interface Scene { id: string; start: number; end: number; x: number }
|
||||
type Line = { at: number; text: string; cmd?: boolean; head?: boolean; final?: boolean };
|
||||
interface Teaser {
|
||||
FORMAT: { w: number; h: number }; DURATION: number; SCENES: Scene[]; MONTAGE: string[];
|
||||
FORMAT: { w: number; h: number }; DURATION: number; FPS: number; SCENES: Scene[]; MONTAGE: string[];
|
||||
CAPTIONS: { id: string; pos: 'top' | 'edge'; text: string; at: number; until: number }[];
|
||||
captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
|
||||
veil(t: number): number;
|
||||
GAME_SCREENS: string[]; GAME_SPEED: number;
|
||||
PRIVACY_ANSWERS: string[]; PRIVACY_STEPS: string[]; DATA_SAFETY_CSV: string[]; DECL_TOTAL: number; PRICE_ROWS: string[]; PRICE_CMD: string;
|
||||
PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
|
||||
REJECT: { head: string; at: string }; PROMPT: string;
|
||||
PROMPT: string; REJECTS: { fixture: string; rule: string; title: string; head: string }[]; REJECT_STEP: number; SAFE: number;
|
||||
STAMP: { at: number; d: number; jolt: number }; REJECT_LAND: { d: number }; REJECT_PUSH: [number, number]; REJECT_DARK: number;
|
||||
ticks: () => { t: number; kind: string }[];
|
||||
BUILD_LINES: Line[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; PRODUCT_LINES: Line[];
|
||||
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string;
|
||||
CONSOLE_CMD: string; CONSOLE_LINES: Line[]; CONSOLES_SWAP: number[];
|
||||
|
|
@ -37,6 +39,7 @@ interface Teaser {
|
|||
END: { markAt: number; wordAt: number; cmdAt: number; cps: number; urlAt: number; d: number; full: number }; END_CMD: string; END_URL: string;
|
||||
SHOT_NOTES: Record<string, string>;
|
||||
}
|
||||
type Box = { x: number; y: number; w: number; h: number };
|
||||
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
|
||||
|
||||
/** Output lines without the clock prefix and the "(0s)" timer, as the video shows them. */
|
||||
|
|
@ -153,11 +156,15 @@ describe('teaser: the scan text is real', () => {
|
|||
let before = '', after = '';
|
||||
beforeAll(() => { before = scanText('teaser', false); after = scanText('teaser-fixed', true); });
|
||||
|
||||
test('reject: the account-deletion finding, its own header and evidence line', () => {
|
||||
const out = lines(before);
|
||||
expect(out).toContain(` 2. ${T.REJECT.head}`);
|
||||
expect(out).toContain(` ${T.REJECT.at}`);
|
||||
expect(T.REJECT.head).toContain('5.1.1(v)');
|
||||
test('reject: each card is a real finding line of `storebound scan` (teaser, teaser-manifest), its rule number in it', () => {
|
||||
const manifest = run(['scan', '--dir', join(FX, 'teaser-manifest')], ROOT, {}).out;
|
||||
for (const f of T.REJECTS) {
|
||||
const out = f.fixture === 'teaser' ? before : manifest;
|
||||
expect(out, f.rule).toMatch(new RegExp(`^ \\d\\. ${f.head.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
||||
expect(f.head, f.rule).toContain(f.rule.replace('Guideline ', ''));
|
||||
expect(f.title.split(' ').length, f.title).toBeLessThanOrEqual(6);
|
||||
}
|
||||
expect(T.REJECTS.map(f => f.rule)).toEqual(['Guideline 2.1(a)', 'Guideline 5.1.1(v)', 'Guideline 5.1.2(i)', 'ITMS-91064']);
|
||||
});
|
||||
test('scan: the risks line, all five findings in order, and the clean re-scan of teaser-fixed', () => {
|
||||
expect(T.SCAN_CMD).toBe('storebound scan');
|
||||
|
|
@ -239,7 +246,7 @@ describe('teaser: timing and format', () => {
|
|||
}
|
||||
for (let i = 1; i < c.length; i++) expect(c[i]!.at, c[i]!.text).toBeGreaterThanOrEqual(c[i - 1]!.gone);
|
||||
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
|
||||
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'One line. Rejected.', 'So my AI did it.',
|
||||
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'Rejected. Again.', 'So my AI did it.',
|
||||
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
|
||||
});
|
||||
test('the cold open moves on frame 0: no fade from black, the screens already rushing past', () => {
|
||||
|
|
@ -257,6 +264,37 @@ describe('teaser: timing and format', () => {
|
|||
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
|
||||
expect(html).toMatch(/#brand \.cmdl \{[^}]*font: 800 7\dpx/);
|
||||
});
|
||||
test('reject: 4 s, the cards land one per beat and all read >= 1 s before the REJECTED stamp hits on a beat, then it darkens', () => {
|
||||
const r = T.SCENES.find(s => s.id === 'reject')!;
|
||||
expect(r.end - r.start).toBe(4);
|
||||
expect(T.REJECTS.length).toBeLessThanOrEqual(5);
|
||||
T.REJECTS.forEach((_, i) => expect(Number.isInteger((r.start + i * T.REJECT_STEP) / 0.5)).toBe(true));
|
||||
expect(T.STAMP.at - T.STAMP.d - ((T.REJECTS.length - 1) * T.REJECT_STEP + T.REJECT_LAND.d)).toBeGreaterThanOrEqual(0.7);
|
||||
expect(Number.isInteger((r.start + T.STAMP.at) / 0.5)).toBe(true);
|
||||
expect(T.ticks().some(x => x.kind === 'stamp' && x.t === r.start + T.STAMP.at)).toBe(true);
|
||||
expect(T.REJECT_DARK).toBeGreaterThan(T.STAMP.at);
|
||||
expect(T.STAMP.jolt).toBeLessThanOrEqual(6); // no shake: one tiny 1-frame jolt at most
|
||||
expect(readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8')).not.toMatch(/shake/);
|
||||
});
|
||||
// The render's layout data: `render.ts --animatic` writes every reject card's box at every frame of the shot.
|
||||
const CHECKS = join(ROOT, 'media/teaser/out/animatic-checks.json');
|
||||
test.skipIf(!existsSync(CHECKS))('reject: every card and the stamp stay inside the 6% safe margins at every frame (animatic layout data)', () => {
|
||||
const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; rejectLayout?: { t: number; cards: Box[]; stamp: Box | null }[] };
|
||||
expect(j.duration).toBe(T.DURATION);
|
||||
const r = T.SCENES.find(s => s.id === 'reject')!;
|
||||
expect(j.rejectLayout?.length).toBe(Math.round((r.end - r.start) * T.FPS));
|
||||
const mx = T.SAFE * T.FORMAT.w, my = T.SAFE * T.FORMAT.h;
|
||||
for (const f of j.rejectLayout!) for (const [k, b] of [...f.cards.map((c, i) => [`card ${i + 1}`, c] as const), ['stamp', f.stamp] as const]) {
|
||||
if (!b) continue;
|
||||
const at = `${k} at ${f.t.toFixed(3)} s`;
|
||||
expect(b.x, at).toBeGreaterThanOrEqual(mx);
|
||||
expect(b.y, at).toBeGreaterThanOrEqual(my);
|
||||
expect(b.x + b.w, at).toBeLessThanOrEqual(T.FORMAT.w - mx);
|
||||
expect(b.y + b.h, at).toBeLessThanOrEqual(T.FORMAT.h - my);
|
||||
}
|
||||
expect(j.rejectLayout!.at(-1)!.cards.length).toBe(T.REJECTS.length);
|
||||
expect(j.rejectLayout!.some(f => f.stamp)).toBe(true);
|
||||
});
|
||||
test('the picture darkens with the rejection and lifts on the drop', () => {
|
||||
const r = T.SCENES.find(s => s.id === 'reject')!, b = T.SCENES.find(s => s.id === 'builds')!;
|
||||
expect(T.veil(r.start)).toBe(0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue