belt-trailer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +78 -0
  3. data/CHANGELOG.md +22 -0
  4. data/LICENSE +21 -0
  5. data/README.md +223 -0
  6. data/engine/bin/trailer.mjs +45 -0
  7. data/engine/lib/args.mjs +18 -0
  8. data/engine/lib/camera.mjs +140 -0
  9. data/engine/lib/check.mjs +134 -0
  10. data/engine/lib/clock.mjs +38 -0
  11. data/engine/lib/config.mjs +97 -0
  12. data/engine/lib/cut.mjs +169 -0
  13. data/engine/lib/ease.mjs +6 -0
  14. data/engine/lib/ffmpeg.mjs +46 -0
  15. data/engine/lib/film.mjs +161 -0
  16. data/engine/lib/index.mjs +5 -0
  17. data/engine/lib/showcase.mjs +83 -0
  18. data/engine/lib/sound.mjs +145 -0
  19. data/engine/lib/titles.mjs +43 -0
  20. data/engine/package.json +14 -0
  21. data/engine/titles.html +144 -0
  22. data/examples/posh-nosh/trailer-titles.css +26 -0
  23. data/examples/posh-nosh/trailer.config.mjs +168 -0
  24. data/lib/belt/commands/trailer_command.rb +123 -0
  25. data/lib/belt/generators/trailer_generator.rb +215 -0
  26. data/lib/belt/trailer/templates/showcase/build.mjs +24 -0
  27. data/lib/belt/trailer/templates/showcase/fixtures.js.erb +22 -0
  28. data/lib/belt/trailer/templates/showcase/showcase.html +14 -0
  29. data/lib/belt/trailer/templates/showcase/showcase.jsx +50 -0
  30. data/lib/belt/trailer/templates/trailer-storyboard.md.erb +37 -0
  31. data/lib/belt/trailer/templates/trailer-titles.css +22 -0
  32. data/lib/belt/trailer/templates/trailer.config.mjs.erb +69 -0
  33. data/lib/belt/trailer/version.rb +7 -0
  34. data/lib/belt/trailer.rb +69 -0
  35. data/lib/belt-trailer.rb +3 -0
  36. data/skills/belt-trailer/SKILL.md +128 -0
  37. data/skills/belt-trailer/references/beats.md +136 -0
  38. data/skills/belt-trailer/references/reviewing.md +90 -0
  39. data/skills/belt-trailer/references/showcase-and-fixtures.md +80 -0
  40. data/skills/belt-trailer/references/storyboard.md +87 -0
  41. metadata +102 -0
@@ -0,0 +1,83 @@
1
+ // Where the pages come from, and what answers their requests.
2
+ //
3
+ // A showcase is the app's screens built from hand-written states (fixtures)
4
+ // with no backend: `showcase.build` is a module in the app that builds it and
5
+ // says where (`buildShowcase()` returning { distDir }, or a `distDir`
6
+ // export). The engine serves the build to Playwright by route interception,
7
+ // so there is no dev server, and the app's API calls get the config's canned
8
+ // answers. `showcase.url` films a running site instead, which works but can't
9
+ // promise the same shot twice.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+ import { contentType } from './sound.mjs';
14
+
15
+ export const ORIGIN = 'http://showcase.local';
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ export const TITLES_PATH = '/__trailer/titles';
18
+ const TITLES_CSS_PATH = '/__trailer/titles.css';
19
+
20
+ export async function prepareShowcase(config) {
21
+ const { showcase, appDir } = config;
22
+ if (showcase.url) {
23
+ console.warn(`Filming ${showcase.url}, a running site: its data can change between takes, so a shot may not come out the same twice.`);
24
+ const url = new URL(showcase.url);
25
+ return { origin: url.origin, entry: url.pathname, dist: null };
26
+ }
27
+ const mod = await import(pathToFileURL(path.resolve(appDir, showcase.build)).href);
28
+ const build = mod.buildShowcase || mod.default;
29
+ if (typeof build !== 'function') throw new Error(`${showcase.build} must export buildShowcase() (or a default function)`);
30
+ const built = await build();
31
+ const distDir = showcase.dist || built?.distDir || mod.distDir;
32
+ if (!distDir) throw new Error(`${showcase.build}: buildShowcase() must return { distDir } (or the module export distDir)`);
33
+ return { origin: ORIGIN, entry: `/${showcase.index || 'showcase.html'}`, dist: path.resolve(appDir, distDir) };
34
+ }
35
+
36
+ // The URL a beat opens: the showcase's page with ?view=<fixture> and the
37
+ // config's and the beat's query parameters.
38
+ export function beatUrl(site, config, beat) {
39
+ const url = new URL(beat.path || site.entry, site.origin);
40
+ if (beat.view) url.searchParams.set('view', beat.view);
41
+ for (const query of [config.showcase.query, beat.query]) {
42
+ const params = typeof query === 'string' ? new URLSearchParams(query) : Object.entries(query || {});
43
+ for (const [k, v] of params) url.searchParams.set(k, String(v));
44
+ }
45
+ return url.toString();
46
+ }
47
+
48
+ // The canned answer for an API path, or undefined. Keys are exact paths, or
49
+ // a prefix ending in `*`; the first that matches wins, so put specific ones
50
+ // first. A value is the JSON to answer with, or a function of the URL that
51
+ // returns it.
52
+ export function cannedAnswer(api, url) {
53
+ for (const [pattern, answer] of Object.entries(api || {})) {
54
+ const hit = pattern.endsWith('*') ? url.pathname.startsWith(pattern.slice(0, -1)) : url.pathname === pattern;
55
+ if (hit) return typeof answer === 'function' ? answer(url) : answer;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ // The route handler for every page the engine opens: the titles page, live
61
+ // sounds, canned API answers, then the showcase build (or, filming a running
62
+ // site, the site itself).
63
+ export function router(site, config, live) {
64
+ return async route => {
65
+ const url = new URL(route.request().url());
66
+ if (url.pathname === TITLES_PATH) {
67
+ return route.fulfill({ body: fs.readFileSync(path.join(here, '../titles.html')), contentType: 'text/html' });
68
+ }
69
+ if (url.pathname === TITLES_CSS_PATH) {
70
+ const css = config.titles.css ? fs.readFileSync(path.resolve(config.appDir, config.titles.css)) : '';
71
+ return route.fulfill({ body: css, contentType: 'text/css' });
72
+ }
73
+ if (live && await live.answer(route, url)) return undefined;
74
+ const canned = await cannedAnswer(config.api, url);
75
+ if (canned !== undefined) return route.fulfill({ json: canned });
76
+ if (!site.dist) return route.continue();
77
+ const file = path.join(site.dist, url.pathname === '/' ? site.entry : decodeURIComponent(url.pathname));
78
+ if (!file.startsWith(site.dist) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
79
+ return route.fulfill({ status: 404, body: '' });
80
+ }
81
+ return route.fulfill({ body: fs.readFileSync(file), contentType: contentType(file) });
82
+ };
83
+ }
@@ -0,0 +1,145 @@
1
+ // Sound is captured, not placed: the app decides what plays and when, and the
2
+ // recorder only writes it down.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { ffmpeg } from './ffmpeg.mjs';
6
+
7
+ // An init script that notes every sound effect the page starts, with how far
8
+ // ahead it was scheduled, in window.__heard. The app fetches each file,
9
+ // decodes it and plays it through Web Audio; the URL is carried from the
10
+ // download (Response.arrayBuffer) through decodeAudioData to the buffer, and
11
+ // noted when an AudioBufferSourceNode starts it. URLs matching `ignore`
12
+ // (music) are left out: music is laid under the whole cut instead.
13
+ export function listen(ignore) {
14
+ const skip = ignore.map(source => new RegExp(source));
15
+ const urlOf = new WeakMap();
16
+ const arrayBuffer = Response.prototype.arrayBuffer;
17
+ Response.prototype.arrayBuffer = async function () {
18
+ const data = await arrayBuffer.call(this);
19
+ urlOf.set(data, this.url);
20
+ return data;
21
+ };
22
+ const decode = BaseAudioContext.prototype.decodeAudioData;
23
+ BaseAudioContext.prototype.decodeAudioData = function (data, ok, fail) {
24
+ const url = urlOf.get(data);
25
+ const tag = buffer => { if (url) urlOf.set(buffer, url); return buffer; };
26
+ const pending = decode.call(this, data, buffer => ok?.(tag(buffer)), fail);
27
+ return pending?.then ? pending.then(tag) : pending;
28
+ };
29
+ const start = AudioBufferSourceNode.prototype.start;
30
+ AudioBufferSourceNode.prototype.start = function (when = 0, ...rest) {
31
+ const url = urlOf.get(this.buffer);
32
+ if (url && !skip.some(re => re.test(url))) {
33
+ (window.__heard ||= []).push({ url, delay: Math.max(0, when - this.context.currentTime) });
34
+ }
35
+ return start.call(this, when, ...rest);
36
+ };
37
+ }
38
+
39
+ // The patterns listen() skips: the config's own, plus every music track that
40
+ // is a plain file, by name.
41
+ export function ignored(config) {
42
+ const own = [config.sounds.ignore].flat().filter(Boolean).map(re => (re instanceof RegExp ? re.source : String(re)));
43
+ const tracks = Object.values(config.music.tracks)
44
+ .filter(t => typeof t === 'string')
45
+ .map(t => `${path.basename(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
46
+ return [...own, ...tracks];
47
+ }
48
+
49
+ // The live site's sounds, when the app lets someone replace them there
50
+ // (sounds.live: { origin, paths }). Requests under those paths are answered
51
+ // from the live site: files (with an extension) are fetched once and kept in
52
+ // sounds.cache, anything else (a list of replacements) is fetched fresh each
53
+ // run. Offline, or with --sounds=bundled, `live` is null and the app gets the
54
+ // config's canned API answers and its bundled files instead.
55
+ export function liveSounds(config, { bundled = false } = {}) {
56
+ const live = config.sounds.live;
57
+ if (bundled || !live?.origin) return null;
58
+ const cache = path.resolve(config.appDir, config.sounds.cache);
59
+ const paths = [live.paths || []].flat();
60
+ const fresh = new Map();
61
+
62
+ const fetchFrom = async pathname => {
63
+ try {
64
+ const response = await fetch(new URL(pathname, live.origin));
65
+ return response.ok ? Buffer.from(await response.arrayBuffer()) : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ const source = {
72
+ cache,
73
+ covers: pathname => paths.some(p => pathname.startsWith(p)),
74
+ fileFor: pathname => path.join(cache, decodeURIComponent(pathname)),
75
+ // The file behind a live path, fetched the first time; null if the live
76
+ // site doesn't have it.
77
+ async file(pathname) {
78
+ const file = source.fileFor(pathname);
79
+ if (fs.existsSync(file)) return file;
80
+ const body = await fetchFrom(pathname);
81
+ if (!body) return null;
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ fs.writeFileSync(file, body);
84
+ return file;
85
+ },
86
+ // The body behind a live path that isn't a file, fetched once a run.
87
+ async body(pathname) {
88
+ if (!fresh.has(pathname)) fresh.set(pathname, fetchFrom(pathname));
89
+ return fresh.get(pathname);
90
+ },
91
+ async json(pathname) {
92
+ const body = await source.body(pathname);
93
+ try { return body ? JSON.parse(body) : null; } catch { return null; }
94
+ },
95
+ // Answers a page's request for a live path, or returns false to let the
96
+ // canned API answer it.
97
+ async answer(route, url) {
98
+ if (!source.covers(url.pathname)) return false;
99
+ if (path.extname(url.pathname)) {
100
+ const file = await source.file(url.pathname);
101
+ if (!file) return false;
102
+ await route.fulfill({ body: fs.readFileSync(file), contentType: contentType(file) });
103
+ return true;
104
+ }
105
+ const body = await source.body(url.pathname + url.search);
106
+ if (!body) return false;
107
+ await route.fulfill({ body, contentType: 'application/json' });
108
+ return true;
109
+ },
110
+ };
111
+ return source;
112
+ }
113
+
114
+ // The file on disk behind a URL the page played: the live site's (cached)
115
+ // or the app's bundled one.
116
+ export function fileFor(config, live, url) {
117
+ const { pathname } = new URL(url, 'http://showcase.local');
118
+ if (live?.covers(pathname)) return live.fileFor(pathname);
119
+ return path.join(config.appDir, config.sounds.bundled, decodeURIComponent(pathname));
120
+ }
121
+
122
+ // Mixes effects into one track `seconds` long: each { at, file } starts `at`
123
+ // seconds in and plays out, level as the app plays it.
124
+ export function mixEffects(sounds, seconds, out) {
125
+ const heard = sounds.filter(s => fs.existsSync(s.file));
126
+ if (!heard.length) {
127
+ ffmpeg(['-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=stereo', '-t', seconds.toFixed(6), out]);
128
+ return;
129
+ }
130
+ const inputs = heard.flatMap(s => ['-i', s.file]);
131
+ const delayed = heard.map((s, i) => {
132
+ const ms = Math.max(0, Math.round(s.at * 1000));
133
+ return `[${i}:a]aformat=sample_rates=48000:channel_layouts=stereo,adelay=${ms}|${ms}[e${i}]`;
134
+ });
135
+ const mix = `${heard.map((_, i) => `[e${i}]`).join('')}amix=inputs=${heard.length}:normalize=0,apad,atrim=0:${seconds.toFixed(6)}[out]`;
136
+ ffmpeg([...inputs, '-filter_complex', [...delayed, mix].join(';'), '-map', '[out]', out]);
137
+ }
138
+
139
+ const TYPES = {
140
+ '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
141
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
142
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4',
143
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
144
+ };
145
+ export const contentType = file => TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream';
@@ -0,0 +1,43 @@
1
+ // Films the titles for a beat: the same number of frames as the beat, each
2
+ // cue played on the frame it was given, on a transparent page the size of the
3
+ // output. Its animations are stepped the way the app's are.
4
+ import path from 'node:path';
5
+ import { freeze, step } from './clock.mjs';
6
+ import { TITLES_PATH } from './showcase.mjs';
7
+
8
+ // The cues with a closing hide added: a title still up near the end leaves
9
+ // `leaveBefore` seconds before the cut, so a shot never ends on words and the
10
+ // next one's can't read as the same title changing, or its dark panel
11
+ // flicker out and back in.
12
+ export function closeTitles(cues, frames, fps, leaveBefore) {
13
+ const last = cues[cues.length - 1];
14
+ if (!last?.show) return cues;
15
+ return [...cues, { frame: Math.max(last.frame + 1, frames - Math.round(leaveBefore * fps)), hide: true }];
16
+ }
17
+
18
+ export async function filmTitles(browser, { site, route, config, format, cues, frames, dir }) {
19
+ const [width, height] = format.out;
20
+ const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
21
+ await page.route(`${site.origin}/**`, route);
22
+ await page.clock.install();
23
+ await page.goto(`${site.origin}${TITLES_PATH}`);
24
+ await page.evaluate(async logo => {
25
+ await document.fonts.ready;
26
+ if (logo) await Object.assign(new Image(), { src: logo }).decode().catch(() => {});
27
+ }, config.titles.endCard?.logo);
28
+ await freeze(page);
29
+ const frameMs = 1000 / config.fps;
30
+ const all = closeTitles(cues, frames, config.fps, config.titles.leaveBefore);
31
+ let now = 0;
32
+ for (let i = 0; i < frames; i += 1) {
33
+ for (const cue of all.filter(c => c.frame === i)) {
34
+ if (cue.show) await page.evaluate(t => window.show(t), cue.show);
35
+ if (cue.hide) await page.evaluate(() => window.hide());
36
+ if (cue.end) await page.evaluate(card => window.endCard(card), config.titles.endCard || {});
37
+ }
38
+ now += frameMs;
39
+ await step(page, now, frameMs);
40
+ await page.screenshot({ path: path.join(dir, `${String(i).padStart(5, '0')}.png`), omitBackground: true });
41
+ }
42
+ await page.close();
43
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "belt-trailer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Films a launch trailer of a web app from its own screens and sounds",
6
+ "type": "module",
7
+ "bin": { "belt-trailer": "bin/trailer.mjs" },
8
+ "exports": {
9
+ ".": "./lib/index.mjs"
10
+ },
11
+ "engines": { "node": ">=20" },
12
+ "peerDependencies": { "playwright": ">=1.45" },
13
+ "scripts": { "test": "node --test test/*.test.mjs" }
14
+ }
@@ -0,0 +1,144 @@
1
+ <!DOCTYPE html>
2
+ <!-- The titles layer: a transparent page the size of the finished video,
3
+ filmed for the same frames as a beat and laid over its clean clip, so the
4
+ camera can move the app without moving the words.
5
+
6
+ window.show({ kicker, line, place }) brings a title in, word by word;
7
+ window.hide() sends it out; window.endCard(card) is the last shot.
8
+ place is side (a panel on the right of a wide frame), band (the strip
9
+ across the middle of a tall one), top or bottom, or { wide, tall }.
10
+
11
+ The look is the app's: its titles css (titles.css in the config) is
12
+ loaded after these defaults. Set the custom properties below, add
13
+ @font-face rules for the app's fonts (served from the showcase build),
14
+ or restyle any class. -->
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="UTF-8" />
18
+ <style>
19
+ :root {
20
+ --title-font: system-ui, sans-serif;
21
+ --line-font: var(--title-font);
22
+ --line-weight: 800;
23
+ --kicker-color: #f5d27a;
24
+ --word-fill: linear-gradient(180deg, #ffffff 0%, #f1f1f1 55%, #cfcfcf 100%);
25
+ --word-glow: rgba(255, 255, 255, 0.25);
26
+ --rule: linear-gradient(90deg, transparent, #f5d27a 30%, #fff3c9 50%, #f5d27a 70%, transparent);
27
+ --shade: 9, 6, 16;
28
+ --end-text: #f4f0e6;
29
+ --end-muted: #cfc7b4;
30
+ --url-text: #1d1405;
31
+ --url-fill: linear-gradient(180deg, #fff1c4 0%, #f5d27a 55%, #d1a23f 100%);
32
+ }
33
+ html, body { margin: 0; height: 100%; background: transparent; overflow: hidden; }
34
+ /* Sizes follow the frame: --u is 1% of its short side. */
35
+ body { --u: 1vmin; font-family: var(--title-font); }
36
+
37
+ /* A dark wash behind the words, at the edge they sit on, fading into the
38
+ picture, so they read over anything without boxing the app in. */
39
+ .scrim { position: fixed; left: 0; right: 0; height: 46%; pointer-events: none; animation: fade-in 0.35s ease-out both; }
40
+ .scrim.top { top: 0; background: linear-gradient(180deg, rgba(var(--shade), 0.94) 0%, rgba(var(--shade), 0.72) 42%, rgba(var(--shade), 0) 100%); }
41
+ .scrim.bottom { bottom: 0; background: linear-gradient(0deg, rgba(var(--shade), 0.94) 0%, rgba(var(--shade), 0.72) 42%, rgba(var(--shade), 0) 100%); }
42
+ .scrim.side { top: 0; bottom: 0; right: 0; left: auto; width: 58%; height: auto;
43
+ background: linear-gradient(90deg, rgba(var(--shade), 0) 0%, rgba(var(--shade), 0.84) 34%, rgba(var(--shade), 0.95) 100%); }
44
+ .scrim.band { top: 56%; height: 30%; background: linear-gradient(180deg, rgba(var(--shade), 0) 0%, rgba(var(--shade), 0.86) 28%, rgba(var(--shade), 0.86) 72%, rgba(var(--shade), 0) 100%); }
45
+
46
+ .title { position: fixed; left: 5%; right: 5%; text-align: center; }
47
+ .title.top { top: calc(var(--u) * 6); }
48
+ .title.bottom { bottom: calc(var(--u) * 7); }
49
+ .title.band { top: 71%; transform: translateY(-50%); }
50
+ .title.side { left: auto; right: 3%; width: 38%; top: 50%; transform: translateY(-50%); }
51
+ .title.side .line { font-size: calc(var(--u) * 8.4); }
52
+ .title.side .kicker { font-size: calc(var(--u) * 2.8); }
53
+ .title.side .rule { width: 60%; }
54
+
55
+ .kicker {
56
+ font-weight: 700; text-transform: uppercase; color: var(--kicker-color);
57
+ font-size: calc(var(--u) * 3.1); letter-spacing: 0.42em; margin-right: -0.42em;
58
+ text-shadow: 0 2px 0 rgba(0, 0, 0, 0.8);
59
+ animation: kicker-in 0.7s cubic-bezier(0.2, 0.8, 0.2, 1) both;
60
+ }
61
+ .line {
62
+ font-family: var(--line-font); font-weight: var(--line-weight);
63
+ font-size: calc(var(--u) * 9); line-height: 1.08; margin-top: calc(var(--u) * 1.2);
64
+ }
65
+ .tall .line { font-size: calc(var(--u) * 9.5); }
66
+ /* Each word lands one after another. */
67
+ .word {
68
+ display: inline-block; margin: 0 0.12em; padding: 0 0.04em;
69
+ background: var(--word-fill); -webkit-background-clip: text; background-clip: text; color: transparent;
70
+ filter: drop-shadow(0 calc(var(--u) * 0.5) 0 rgba(0, 0, 0, 0.75)) drop-shadow(0 0 calc(var(--u) * 2.4) var(--word-glow));
71
+ animation: word-in 0.6s cubic-bezier(0.2, 1.5, 0.35, 1) both;
72
+ animation-delay: calc(0.15s + var(--i) * 0.09s);
73
+ }
74
+ .rule {
75
+ height: calc(var(--u) * 0.35); width: 42%; margin: calc(var(--u) * 1.6) auto 0;
76
+ background: var(--rule); animation: rule-in 0.7s 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) both;
77
+ }
78
+ .leaving { animation: leave 0.3s ease-in both !important; }
79
+ .leaving * { animation-play-state: paused; }
80
+
81
+ /* The last shot: the logo, the offer and the address. */
82
+ .end { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center;
83
+ background: radial-gradient(ellipse at 50% 42%, rgba(var(--shade), 0.55) 0%, rgba(var(--shade), 0.9) 70%); animation: fade-in 0.4s ease-out both; }
84
+ .end img { width: calc(var(--u) * 52); animation: logo-in 0.8s cubic-bezier(0.2, 1.4, 0.35, 1) 0.1s both; }
85
+ .tall .end img { width: calc(var(--u) * 78); }
86
+ .end .pitch { margin-top: calc(var(--u) * 3); font-weight: 700; color: var(--end-text); font-size: calc(var(--u) * 4.4); animation: rise 0.5s ease-out 0.55s both; }
87
+ .end .terms { margin-top: calc(var(--u) * 1.2); color: var(--end-muted); font-size: calc(var(--u) * 3); letter-spacing: 0.08em; animation: rise 0.5s ease-out 0.75s both; }
88
+ .end .url {
89
+ margin-top: calc(var(--u) * 4); padding: calc(var(--u) * 1.4) calc(var(--u) * 4); border-radius: 999px;
90
+ font-weight: 800; font-size: calc(var(--u) * 4.6); color: var(--url-text); letter-spacing: 0.04em; background: var(--url-fill);
91
+ box-shadow: 0 calc(var(--u) * 1) calc(var(--u) * 3) rgba(0, 0, 0, 0.6);
92
+ animation: pop 0.55s cubic-bezier(0.2, 1.5, 0.35, 1) 1s both;
93
+ }
94
+
95
+ @keyframes fade-in { from { opacity: 0; } }
96
+ @keyframes kicker-in { from { opacity: 0; letter-spacing: 1.1em; } }
97
+ @keyframes word-in { from { opacity: 0; transform: translateY(0.35em) scale(1.35); filter: blur(calc(var(--u) * 1)); } }
98
+ @keyframes rule-in { from { transform: scaleX(0); opacity: 0; } }
99
+ @keyframes leave { to { opacity: 0; transform: translateY(calc(var(--u) * -2)); } }
100
+ @keyframes logo-in { from { opacity: 0; transform: scale(0.6) rotate(-4deg); } }
101
+ @keyframes rise { from { opacity: 0; transform: translateY(calc(var(--u) * 2)); } }
102
+ @keyframes pop { from { opacity: 0; transform: scale(0.6); } }
103
+ </style>
104
+ <link rel="stylesheet" href="/__trailer/titles.css" />
105
+ </head>
106
+ <body>
107
+ <script>
108
+ const tall = innerHeight > innerWidth;
109
+ document.body.classList.toggle('tall', tall);
110
+ const el = (className, text) => Object.assign(document.createElement('div'), { className, textContent: text ?? '' });
111
+ let shown = [];
112
+ window.show = ({ kicker, line, place }) => {
113
+ window.hide(true);
114
+ // By default beside the picture in a wide frame, in the band of a tall one.
115
+ place = (typeof place === 'object' ? place?.[tall ? 'tall' : 'wide'] : place) || (tall ? 'band' : 'side');
116
+ const scrim = el(`scrim ${place}`);
117
+ const title = el(`title ${place}`);
118
+ if (kicker) title.append(el('kicker', kicker));
119
+ const words = el('line');
120
+ line.split(' ').forEach((text, i) => {
121
+ const word = Object.assign(document.createElement('span'), { className: 'word', textContent: text });
122
+ word.style.setProperty('--i', i);
123
+ words.append(word);
124
+ });
125
+ title.append(words, el('rule'));
126
+ document.body.append(scrim, title);
127
+ shown = [scrim, title];
128
+ };
129
+ window.hide = (now = false) => {
130
+ shown.forEach(e => (now ? e.remove() : e.classList.add('leaving')));
131
+ if (now) shown = [];
132
+ };
133
+ window.endCard = ({ logo, alt, lines = [], url } = {}) => {
134
+ window.hide(true);
135
+ const end = el('end');
136
+ if (logo) end.append(Object.assign(document.createElement('img'), { src: logo, alt: alt || '' }));
137
+ lines.forEach((text, i) => end.append(el(i === 0 ? 'pitch' : 'terms', text)));
138
+ if (url) end.append(el('url', url));
139
+ document.body.append(end);
140
+ shown = [end];
141
+ };
142
+ </script>
143
+ </body>
144
+ </html>
@@ -0,0 +1,26 @@
1
+ /* Posh Nosh's titles: Cinzel, in gilt, over a near-black wash. The fonts and
2
+ the logo come from the showcase build (public/). */
3
+ @font-face { font-family: 'Cinzel'; font-weight: 500 900; src: url(/fonts/cinzel-latin.woff2) format('woff2'); }
4
+ @font-face { font-family: 'Cinzel Decorative'; font-weight: 900; src: url(/fonts/cinzel-decorative-900-latin.woff2) format('woff2'); }
5
+ @font-face { font-family: 'Cinzel Decorative'; font-weight: 700; src: url(/fonts/cinzel-decorative-700-latin.woff2) format('woff2'); }
6
+
7
+ :root {
8
+ --title-font: 'Cinzel', Georgia, serif;
9
+ --line-font: 'Cinzel Decorative', 'Cinzel', serif;
10
+ --line-weight: 900;
11
+ --kicker-color: #e6bf5c;
12
+ --word-fill: linear-gradient(180deg, #fff7dc 0%, #f6dd94 38%, #e6bf5c 60%, #b0802c 100%);
13
+ --word-glow: rgba(230, 191, 92, 0.35);
14
+ --rule: linear-gradient(90deg, transparent, #e6bf5c 30%, #fff1c4 50%, #e6bf5c 70%, transparent);
15
+ --shade: 9, 6, 16;
16
+ --end-text: #fbe9b5;
17
+ --end-muted: #d9c9a4;
18
+ --url-text: #2a1a06;
19
+ --url-fill: linear-gradient(180deg, #fbe9b5 0%, #e6bf5c 55%, #c99a3a 100%);
20
+ }
21
+
22
+ .end { background: radial-gradient(ellipse at 50% 42%, rgba(30, 18, 44, 0.55) 0%, rgba(9, 6, 16, 0.9) 70%); }
23
+ .end img { filter: drop-shadow(0 0 calc(var(--u) * 4) rgba(230, 191, 92, 0.45)); }
24
+ .end .url {
25
+ box-shadow: 0 0 0 calc(var(--u) * 0.3) #7a5a1e, 0 calc(var(--u) * 1) calc(var(--u) * 3) rgba(0, 0, 0, 0.6), 0 0 calc(var(--u) * 5) rgba(230, 191, 92, 0.4);
26
+ }
@@ -0,0 +1,168 @@
1
+ // Posh Nosh's launch trailer (Fizzy #1818) as a belt-trailer config: the
2
+ // storyboard in posh_nosh docs/launch-video.md, filmed from the screenshot
3
+ // showcase (frontend/scripts/screenshots/). Lives in frontend/.
4
+
5
+ // The table without the chronicle and chat beside it: the units and the hand.
6
+ const TABLE = ['.unit', '.hand .pcard'];
7
+ // Both ranks with room on the right for the words: where the Skeleton's shot
8
+ // ends and the Dragon's begins.
9
+ const BOTH_RANKS = ['.unit.ally', '.unit.foe'];
10
+ const bothRanksRoom = cam => ({ pad: 0.06, right: cam.tall ? 0 : 0.6 });
11
+ const card = idx => `.hand .pcard[data-hand-idx="${idx}"]`;
12
+
13
+ // The file a music slot plays, as the game chooses it (sound.js): the live
14
+ // upload, else what it stands in for, else the bundled theme.
15
+ const STAND_INS = { 'music-battle-75': 'music-battle-50', 'music-battle-50': 'music-battle' };
16
+ const THEME = 'public/sounds/the-great-march.mp3';
17
+ const slot = name => async ({ live }) => {
18
+ const board = live && await live.json('/api/sounds');
19
+ for (let s = name; s; s = STAND_INS[s]) {
20
+ const own = board?.sounds?.find(x => x.slot === s)?.url;
21
+ if (own) return (await live.file(own)) || THEME;
22
+ }
23
+ return THEME;
24
+ };
25
+
26
+ export default {
27
+ showcase: { build: './scripts/screenshots/build.mjs', query: { sound: 1 } },
28
+ api: {
29
+ '/api/matches': { matches: [] },
30
+ // The live soundboard answers this when it can (sounds.live); offline, or
31
+ // with --sounds bundled, no replacements, so the bundled files play.
32
+ '/api/sounds': { sounds: [] },
33
+ '/api/*': {},
34
+ },
35
+ // An admin can replace any sound at /admin/sounds; the live site lists the
36
+ // replacements at /api/sounds and serves them under /soundboard/.
37
+ sounds: {
38
+ bundled: 'public',
39
+ live: { origin: 'https://playposhnosh.com', paths: ['/api/sounds', '/soundboard/'] },
40
+ ignore: ['/music-', 'the-great-march'],
41
+ },
42
+ page: {
43
+ // The chronicle and chat beside the table are never the subject.
44
+ css: '.combat-log { visibility: hidden; }',
45
+ init: () => {
46
+ sessionStorage.clear();
47
+ localStorage.setItem('posh-nosh-sound-on', '1');
48
+ localStorage.setItem('posh-nosh-welcome', 'seen');
49
+ },
50
+ },
51
+ titles: {
52
+ css: './trailer-titles.css',
53
+ endCard: {
54
+ logo: '/images/brand/posh-nosh-logo.png',
55
+ alt: 'Posh Nosh',
56
+ lines: ['A card battle for two', 'Free in your browser · No account needed'],
57
+ url: 'playposhnosh.com',
58
+ },
59
+ },
60
+ // The game's rule: the table's track while the battle is young, its "50"
61
+ // track once the fight has turned, silence from the win (the victory beat's
62
+ // hush mark), the lobby's music under the end card. --music theme plays the
63
+ // lobby theme throughout.
64
+ music: {
65
+ fade: 1.5,
66
+ tracks: { battle: slot('music-battle'), 'battle-50': slot('music-battle-50'), lobby: slot('music-lobby'), theme: THEME },
67
+ plan: { hook: 'battle', recruit: 'battle', strike: 'battle', dragon: 'battle-50', banter: 'battle-50', victory: 'battle-50', end: 'lobby' },
68
+ plans: { theme: { hook: 'theme', recruit: 'theme', strike: 'theme', dragon: 'theme', banter: 'theme', victory: 'theme', end: 'theme' } },
69
+ },
70
+ beats: [
71
+ // The hook: no words, a card thrown, a rival falls, the camera slams in.
72
+ { name: 'hook', view: 'battle-my-turn-fresh', play: async ({ cam, setView, drag }) => {
73
+ // Wide, the hand is below the frame and the card flies up into it.
74
+ await cam.focus(cam.tall ? TABLE : '.unit', { pad: 0.03 });
75
+ await cam.focus(cam.tall ? ['.unit.foe', '.hand .pcard'] : '.unit', { ms: 900, pad: cam.tall ? 0.04 : -0.02 });
76
+ await cam.hold(200);
77
+ await drag(card(0), '.unit.foe >> nth=1', { ms: 600 });
78
+ await setView('trailer-knockout');
79
+ await cam.punch('.unit.foe >> nth=1', { pad: 0.3 });
80
+ cam.shake(400, 10);
81
+ await cam.hold(1600);
82
+ } },
83
+ // Pick a warband: the camera glides along the roster close enough to see
84
+ // the art. The pointer stays off the portraits, so no tooltip covers them.
85
+ { name: 'recruit', view: 'selection-partial',
86
+ // The title stands in for the screen's own heading.
87
+ css: '.recruit-screen .phase-header { visibility: hidden; }',
88
+ play: async ({ page, cam, ease }) => {
89
+ cam.title({ kicker: 'Step one', line: 'Choose your champions', place: 'top' });
90
+ const row = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => `.recruit-portrait >> nth=${from + i}`);
91
+ await page.mouse.move(2, page.viewportSize().height - 2);
92
+ await cam.focus(row(0, 3), { pad: 0.08, top: 0.55 });
93
+ await cam.focus(row(3, 6), { ms: 3200, pad: 0.08, top: 0.55, ease: ease.linear });
94
+ await cam.hold(3300);
95
+ } },
96
+ // Out-play: the Necromancer's ritual, slow enough to follow, and a
97
+ // Skeleton rises. The words wait for the Skeleton.
98
+ { name: 'strike', view: 'trailer-ritual', play: async ({ page, cam, setView, ease }) => {
99
+ await cam.focus('.ability-dialog', { pad: 0.12 });
100
+ await cam.focus('.ability-dialog', { ms: 2600, pad: 0.04, ease: ease.linear });
101
+ await cam.hold(700);
102
+ // No pointer on the cards: a hovered card lifts, then lifts again when picked.
103
+ await page.mouse.move(2, 2);
104
+ await cam.hold(450);
105
+ await setView('trailer-ritual-ready');
106
+ await cam.hold(1300);
107
+ await setView('trailer-risen');
108
+ await cam.focus(BOTH_RANKS, { pad: 0.05, right: 0.6 });
109
+ await cam.hold(250);
110
+ cam.title({ kicker: 'Every unit has an ability', line: 'Out-play your rival' });
111
+ await cam.punch('.unit.ally >> nth=4', { pad: 0.9, right: 0.8, top: 0.2, ms: 500 });
112
+ cam.shake(300, 6);
113
+ await cam.hold(1500);
114
+ // Back out to the frame the Dragon's shot opens on.
115
+ await cam.focus(BOTH_RANKS, { ms: 1000, ...bothRanksRoom(cam) });
116
+ await cam.hold(1100);
117
+ } },
118
+ // A Dragon is summoned, and the camera meets it.
119
+ { name: 'dragon', view: 'trailer-dragon-offer', play: async ({ cam, setView, ease }) => {
120
+ await cam.focus(BOTH_RANKS, bothRanksRoom(cam));
121
+ await cam.focus('.unit.ally', { ms: 900, pad: 0.12, top: cam.tall ? 0.9 : 0.4, right: cam.tall ? 0 : 0.7, ease: ease.linear });
122
+ await cam.hold(700);
123
+ await setView('trailer-dragon');
124
+ await cam.hold(150);
125
+ // A push of more than 1.5× takes 0.9 s; at 0.5 s it snapped.
126
+ await cam.focus('.summoned-dragon', { pad: 0.5, top: cam.tall ? 0.9 : 0.1, right: cam.tall ? 0 : 0.7, ms: 900 });
127
+ cam.shake(400, 5);
128
+ await cam.hold(350);
129
+ cam.title({ kicker: 'When manners fail', line: 'Summon a dragon', place: { tall: 'top' } });
130
+ await cam.hold(2900);
131
+ } },
132
+ // Table talk: each bubble is framed once it has popped.
133
+ { name: 'banter', view: 'trailer-banter', query: 'chat=phrases', play: async ({ cam, fire, ease }) => {
134
+ await cam.focus(TABLE, { pad: 0.03 });
135
+ await cam.hold(200);
136
+ await fire('__say', 'Is that all you’ve got?', '1');
137
+ await cam.hold(450);
138
+ await cam.focus(['.speech-bubble.theirs', '.unit.foe >> nth=3'], { ms: 450, pad: 0.35, ease: ease.out });
139
+ await cam.hold(1300);
140
+ await fire('__say', 'You’ll pay for that!', '0');
141
+ await cam.hold(400);
142
+ await cam.focus(['.speech-bubble.mine', '.unit.ally >> nth=0'], { ms: 500, pad: 0.3, ease: ease.out });
143
+ await cam.hold(1500);
144
+ } },
145
+ // Victory: the final blow, and only then the win.
146
+ { name: 'victory', view: 'trailer-last-stand', play: async ({ cam, setView, drag, ease }) => {
147
+ await cam.focus(['.unit.foe', '.hand .pcard'], { pad: 0.04 });
148
+ await cam.hold(300);
149
+ await drag(card(0), '.unit.foe >> nth=3', { ms: 600 });
150
+ await setView('trailer-final-blow');
151
+ await cam.punch('.unit.foe >> nth=3', { pad: 0.5 });
152
+ cam.shake(450, 10);
153
+ await cam.hold(1400);
154
+ await setView('trailer-won');
155
+ cam.mark('hush');
156
+ await cam.hold(100);
157
+ cam.title({ line: 'Leave no one standing', place: 'bottom' });
158
+ await cam.focus(['.gameover-icon', '.gameover-title'], { ms: 2600, pad: 0.5, bottom: 1.2, ease: ease.out });
159
+ await cam.hold(3000);
160
+ } },
161
+ // The end card over the Great Hall, softened behind it.
162
+ { name: 'end', view: 'lobby', css: '#root { filter: blur(5px) brightness(0.6) saturate(0.8); }', play: async ({ cam, ease }) => {
163
+ await cam.focus('#root', { pad: -0.08, ms: 3800, ease: ease.linear });
164
+ cam.endCard();
165
+ await cam.hold(3800);
166
+ } },
167
+ ],
168
+ };