teaser v9 final: music reworked for the tight cut (drives from frame 0, an accent on every cut, tension in the pile and the rejection, a short drop into the request, layers building through the montage, the stop on Submit, the resolve on Waiting for review, a confident end under the card fading over the last 1 s; -16 LUFS, true peak <= -1.5 dBTP); 1920x1080 master, web version and posters from the end card; site teaser and poster (cache key v=5), README poster

This commit is contained in:
CRTkafa 2026-10-14 01:03:51 +08:00
parent c608b9f63e
commit 0c4689a81b
7 changed files with 57 additions and 35 deletions

View file

@ -9,15 +9,15 @@ Real product footage only. Every frame is a pure function of time, rendered from
Chrome and encoded with ffmpeg; the music and sound design are synthesized in the same browser (Web Audio
`OfflineAudioContext`): no samples, nothing to license.
120 BPM: every cut is a hard cut on a beat (whole half seconds). Before any final render, make the animatic
120 BPM: every cut is a hard cut on a beat (whole half seconds), with a sound accent on each. Before any final render, make the animatic
(`--animatic`: a 960x540 draft with sound, a one-frame-per-second contact sheet and the shot list) and get the owner's OK.
| s | shows | text | music |
|---|---|---|---|
| 0-3 | the demo game's own screens rush past, a push in | Your game is done. | calm pad, a pluck |
| 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, 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 |
| 9.5-12 | one request typed to the AI agent, sent on the beat | So my AI did it. | near silence, key clicks, a swell |
| 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 |
@ -26,7 +26,7 @@ Chrome and encoded with ffmpeg; the music and sound design are synthesized in th
| 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) | the chord holds |
| 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 |
The demo game has 4 languages (en, tr, ja, ar), so the screenshot beat says "every size", not a language count.

View file

@ -1,13 +1,15 @@
// Original teaser music and sound design (v8, a story arc), synthesized from nothing with the Web Audio API
// Original teaser music and sound design (v9, the tight cut), synthesized from nothing with the Web Audio API
// (OfflineAudioContext): no samples, no downloaded audio. 120 BPM (a beat every half second; every cut lands on one).
// calm game a warm D major pad, a slow soft pluck
// tension pile B minor, a low pulse in eighths that grows, a ticking clock, a riser; a thud per card
// dark reject a low boom, a dark drone that sinks to almost nothing
// silence ask room tone, the key clicks of the request, one soft note when it is sent; a swell into the drop
// calm game drives from frame 0: kick on every beat, D major bass eighths, a bright arpeggio (no slow intro)
// tension pile B minor, a low pulse in eighths that grows, kicks, a ticking clock, a riser; a thud per card
// dark reject a low boom, a dark drone, the pulse keeps ticking underneath
// silence ask a short drop: an impact on the cut, then a filtered half-time beat, the key clicks, a swell
// (every cut) an accent: a short noise crash and a low hit
// drive montage+scan kick, bass, arpeggio, hats, claps: one layer more per montage beat (I-vi-IV-V, a chord per bar);
// a chime per fix, rising
// stop button the music stops on the cut; only a quiet low pulse, like holding your breath
// resolve waiting+end the click, then a wide D major add9 swell, bells, a slow arpeggio under the end card
// resolve waiting+end the click, then a wide D major add9 swell, bells; under the end card a confident kick and
// bass on D, the final chord; render.ts fades the last 1 s
// Deterministic. renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg.
import { CLICK_T, CUTS, DURATION, keyTimes, MONTAGE, scene, ticks } from './script.js';
@ -133,10 +135,10 @@ export async function renderMusic() {
const v = ctx.createGain(); env(v, t, 0.001, g, open ? 0.14 : 0.035); n.connect(hp); hp.connect(v); v.connect(p); p.connect(drive);
}
/** The bass: a filtered saw + sine, one note. */
function bass(t, m, len, g = 0.11, cutoff = 520) {
function bass(t, m, len, g = 0.11, cutoff = 520, to = drive) {
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(cutoff * 2.2, t); lp.frequency.exponentialRampToValueAtTime(cutoff, t + len * 0.8); lp.Q.value = 2;
const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + 0.008); v.gain.setValueAtTime(g * 0.8, t + len * 0.7); v.gain.linearRampToValueAtTime(0, t + len);
lp.connect(v); v.connect(drive);
lp.connect(v); v.connect(to);
for (const [type, mul] of [['sawtooth', 0.6], ['sine', 1]]) {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m);
const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(lp); o.start(t); o.stop(t + len + 0.05);
@ -160,13 +162,18 @@ export async function renderMusic() {
tick(t, 0.09, 4200); tick(t + 0.055, 0.05, 2800);
}
// ---------- calm: the game is done ----------
// ---------- calm: the game is done (drives from frame 0) ----------
{
const [a, b] = S.calm;
const [r, iv] = CH.Dmaj9;
pad(0, S.calm[1] + 0.2, [r, r + iv[1], r + iv[2], r + iv[3]], 0.032, 900, 1.6, 1.2);
pad(0, S.calm[1] + 0.2, [r - 12], 0.02, 300, 1.6, 1.0);
const tones = [62, 66, 69, 73, 74, 73, 69, 66];
for (let t = 0.5, k = 0; t < S.calm[1] - 0.1; t += 2 * STEP, k++) pluck(t, tones[k % tones.length], 0.036, k % 2 ? 0.25 : -0.25, 1.6);
pad(0, b + 0.05, [r, r + iv[1], r + iv[2], r + iv[3]], 0.026, 1400, 0.02, 0.15, master, 2600);
for (let t = a, k = 0; t < b - 0.01; t += STEP, k++) {
kick(t, k % 4 === 0 ? 0.52 : 0.44, master);
hat(t + STEP / 2, 0.024);
bass(t, 38, STEP / 2 * 0.85, 0.1); bass(t + STEP / 2, k % 4 === 3 ? 45 : 38, STEP / 2 * 0.85, 0.085);
const tones = [74, 78, 81, 85];
for (let j = 0; j < 4; j++) pluck(t + j * STEP / 4, tones[(k * 4 + j) % 4], 0.028 * (j === 0 ? 1.2 : 1), j % 2 ? 0.3 : -0.3, 0.3, master);
}
}
// ---------- tension: the stores pile up ----------
@ -179,10 +186,11 @@ export async function renderMusic() {
// the low pulse: eighths on B, louder and brighter towards the rejection
for (let t = a, k = 0; t < b - 0.01; t += STEP / 2, k++) {
const p = (t - a) / (b - a);
bass(t, k % 8 === 7 ? 42 : 35, STEP / 2 * 0.9, 0.05 + 0.07 * p, 260 + 900 * p);
bass(t, k % 8 === 7 ? 42 : 35, STEP / 2 * 0.9, 0.07 + 0.06 * p, 300 + 900 * p);
if (k % 2 === 0) kick(t, 0.42 + 0.1 * p, master);
}
// a clock ticking in sixteenths over the second half
for (let t = a + 3, k = 0; t < b - 0.01; t += STEP / 4, k++) tick(t, (k % 4 === 0 ? 0.03 : 0.016) * (0.6 + (t - a) / (b - a) * 0.6), k % 4 === 0 ? 5200 : 6800);
for (let t = a + 1, k = 0; t < b - 0.01; t += STEP / 4, k++) tick(t, (k % 4 === 0 ? 0.03 : 0.016) * (0.6 + (t - a) / (b - a) * 0.6), k % 4 === 0 ? 5200 : 6800);
riser(b, 3, 0.07, 300, 6000);
}
@ -193,17 +201,23 @@ export async function renderMusic() {
const v = pad(a, b - 1.2, [35, 38, 42, 47], 0.04, 420, 0.1, 1.6, master, 260);
void v;
pad(a, b - 1.4, [59, 60], 0.008, 1800, 1.0, 1.4); // a faint, uneasy cluster above
// the clock keeps ticking: nothing stands still
for (let t = a + STEP, k = 0; t < b - 0.01; t += STEP / 2, k++) tick(t, k % 2 ? 0.014 : 0.026, k % 2 ? 6800 : 5200);
for (let t = a + 2 * STEP; t < b - 0.01; t += STEP) bass(t, 35, STEP * 0.4, 0.06, 240);
}
// ---------- near silence: the request ----------
// ---------- the drop: the request ----------
{
const [a, b] = S.silence;
// room tone: very quiet, dark noise
const n = noiseSrc(a, b - a); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
const v = ctx.createGain(); v.gain.setValueAtTime(0, a); v.gain.linearRampToValueAtTime(0.012, a + 0.8); v.gain.setValueAtTime(0.012, b - 0.6); v.gain.linearRampToValueAtTime(0, b);
n.connect(lp); lp.connect(v); v.connect(master);
pluck(a + 0.5, 66, 0.022, 0, 2.5);
// the swell into the drop
// a short drop on the cut: one impact, then a filtered half-time beat under the key clicks
kick(a, 0.6, master); boom(a, 0.25);
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(500, a); lp.frequency.exponentialRampToValueAtTime(5000, b); lp.connect(master);
for (let t = a + STEP, k = 1; t < b - 0.01; t += STEP, k++) {
if (k % 2 === 0) kick(t, 0.4, master);
hat(t + STEP / 2, 0.012 + 0.012 * (t - a) / (b - a));
for (let j = 0; j < 2; j++) pluck(t + j * STEP / 2, [62, 66, 69, 74][(k * 2 + j) % 4] + 12, 0.03, j ? 0.25 : -0.25, 0.4, lp);
}
// the swell into the montage
riser(b, 1.5, 0.08, 250, 7000);
pad(b - 1.5, b, [62, 69, 74], 0.02, 600, 1.4, 0.05, master, 3000);
}
@ -266,13 +280,21 @@ export async function renderMusic() {
const e = scene('end').start;
bell(e + 0.3, r + 24, 0.05);
bell(e + 0.55, r + 31, 0.035, 0.2);
// a confident ending: kick and bass on D under the card, the full chord on the last bar
for (let t = e, k = 0; t < b - 0.01; t += STEP, k++) {
kick(t, k % 4 === 0 ? 0.48 : 0.36, master);
bass(t, 38, STEP * 0.8, 0.09, 600, master);
if (k % 2) tick(t, 0.02, 8000);
}
pad(e, b + 0.6, [r, r + 7, r + 12, r + 16, r + 19], 0.03, 2400, 0.05, 1.0);
}
// ---------- soft accents on the story cuts ----------
// ---------- an accent on every cut ----------
for (const c of CUTS) {
if (MONTAGE.includes(c.id) || c.id === 'scan') continue;
if (c.id === 'reject' || c.id === 'builds') continue; // the boom and the drop are the accents
bell(c.t + 0.01, c.id === 'pile' ? 71 : 74, 0.03, 0.1);
if (c.id === 'button') { bell(c.t + 0.01, 86, 0.04); continue; } // the stop: the music is cut, one bell
const n = noiseSrc(c.t, 0.6); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3500;
const v = ctx.createGain(); env(v, c.t, 0.002, 0.07, 0.45); n.connect(hp); hp.connect(v); v.connect(sfx); v.connect(sfxVerb);
if (c.id !== 'reject') thud(c.t, 0.14);
}
// ---------- UI sounds ----------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Before After
Before After

View file

@ -155,7 +155,7 @@ async function main(): Promise<void> {
const j = JSON.parse(m.stderr.toString().match(/\{[\s\S]*?\}/)![0]) as Record<string, string>;
const gain = Math.min(-16 - Number(j.input_i), -1.5 - Number(j.input_tp));
log(`sound: ${j.input_i} LUFS, LRA ${j.input_lra} LU, true peak ${j.input_tp} dBTP; gain ${gain.toFixed(2)} dB -> ${(Number(j.input_i) + gain).toFixed(1)} LUFS`);
ff('-i', raw, '-af', `volume=${gain.toFixed(2)}dB,afade=t=out:st=${(dur - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
ff('-i', raw, '-af', `volume=${gain.toFixed(2)}dB,afade=t=out:st=${(dur - 1).toFixed(2)}:d=1,aresample=48000`,
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music);
if (!soundOnly) {
const silent = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}-silent.mp4`), out = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}.mp4`);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

View file

@ -73,8 +73,8 @@
<figure class="hero-video" id="teaser" aria-labelledby="teaser-cap">
<div class="player" data-player>
<video controls preload="none" playsinline width="1280" height="720" poster="img/teaser-poster.webp?v=4" aria-label="storebound teaser video, with music">
<source src="img/teaser.mp4?v=4" type="video/mp4">
<video controls preload="none" playsinline width="1280" height="720" poster="img/teaser-poster.webp?v=5" aria-label="storebound teaser video, with music">
<source src="img/teaser.mp4?v=5" type="video/mp4">
</video>
<button class="play" type="button" hidden aria-label="Play the teaser (with sound)"><span class="ic ic-play" aria-hidden="true"></span><span>Watch the teaser</span></button>
</div>