belt-trailer 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed9f2883e65374fed7efa5bddb143815548b666eeef890a4131c8a9fd2cdb0d7
4
- data.tar.gz: db1be41d567472e59d903753807bef4931343bfacd16bb5d86759f19cbb21faf
3
+ metadata.gz: 6305cc863de1b96aa6052687e9bfb058d37173d8c87a9e1bd2e2e80f2c686782
4
+ data.tar.gz: 29d6ad1a84d46e9e70b8118e86499c3310b7a0750d49fb0a5a8a22923c230366
5
5
  SHA512:
6
- metadata.gz: cbcbcb3fca347996debac439fea243eaf69046f91194f3c63468f3066dfd5a27277a0f5007230dbfbd02a1888e3f40b3a5ae253b0b8ad51adf977c541f21c064
7
- data.tar.gz: 8e950ded3ad819261b365ea9b2e2baa0db8e2187bf65e67325429c50d0090ae3a365c2f0530716516e509cd9e2c3126def5090dd27f71b8b2f852703c61ce3e9
6
+ metadata.gz: b48f3596c962441ceebb65c7e2567e0d2982f05564e714d31a695e4fb23115a9138a4ecc253ee54d991569dc87d795854a660c8ca8115d265a10eb9ea51cfd21
7
+ data.tar.gz: fb5d0a83e35c95e3be7f8504becb333ef606c0bb2a20843ab5f5b5fde0982dc82f1cfa62434144879cdff34b21b2cff5f7e9ae3ebf8e787b8b7afd83083b2a30
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Transitions between beats (Fizzy #1967): `transition: { type, ms }` in the
6
+ config, or per beat for the change into it. `dissolve`, `dip`, `dip-white`,
7
+ `slide`, `slide-right` and `wipe` overlap the two beats with ffmpeg `xfade`;
8
+ `cut` (the default) joins them end to end as before, without re-encoding.
9
+ Beat starts in `trailer-<format>.json`, effects and music follow the overlap.
10
+ Titles leave before the transition out of their beat, so no words show
11
+ through a dissolve. FeatureParity's trailer jumped between unrelated
12
+ framings at every change of page; hard cuts were all the engine could do.
13
+
3
14
  ## 0.1.0
4
15
 
5
16
  - The engine, split out of Posh Nosh's launch trailer (Fizzy #1818) and made
data/README.md CHANGED
@@ -90,7 +90,7 @@ All take `--frontend NAME` (from `config/frontends.yml`; default `frontend/`), `
90
90
 
91
91
  `--clean` deletes those beats' old clips, leftover frame directories and the cached live sounds first. `--sounds bundled` plays the app's bundled files rather than the live site's replacements.
92
92
 
93
- **cut** joins the beats in config order, lays every effect where the app played it (ringing on past its own beat) and the music by the plan, and levels the whole thing. Writes `trailer-<format>.mp4` and `trailer-<format>.json` (where each beat starts, and every audio file in the cut). `--music PLAN` cuts with a named plan from `music.plans` into `trailer-<format>-<PLAN>.mp4`.
93
+ **cut** joins the beats in config order (with a hard cut or a transition between each pair, see `transition` below), lays every effect where the app played it (ringing on past its own beat) and the music by the plan, and levels the whole thing. Writes `trailer-<format>.mp4` and `trailer-<format>.json` (where each beat starts, and every audio file in the cut). `--music PLAN` cuts with a named plan from `music.plans` into `trailer-<format>-<PLAN>.mp4`.
94
94
 
95
95
  **check** passes or fails a cut, exiting 1 on a fail:
96
96
 
@@ -147,6 +147,11 @@ export default {
147
147
  fps: 30,
148
148
  settle: 1500, // ms the page runs before a beat starts
149
149
  check: { maxSeconds: 30 },
150
+ // How the cut goes from one beat into the next: 'cut' (the default),
151
+ // 'dissolve', 'dip' (through black), 'dip-white', 'slide', 'slide-right'
152
+ // or 'wipe', over `ms`. A beat can set its own, for the change into it:
153
+ // transition: 'dip' or { type: 'dissolve', ms: 400 }.
154
+ transition: { type: 'dissolve', ms: 500 },
150
155
 
151
156
  beats: [
152
157
  { name: 'hook', view: 'battle-start', query: 'chat=on', css: '…', settle: 500,
@@ -192,6 +197,8 @@ Behind query flags, so the showcase stays quiet for screenshots:
192
197
 
193
198
  **Sound is captured, not placed.** An init script tags each `Response.arrayBuffer` with its URL, carries the tag through `decodeAudioData` to the `AudioBuffer`, and records `AudioBufferSourceNode.start(when)` with its scheduled delay. Each frame the recorder drains the list and notes the frame. Chromium runs with `--autoplay-policy=no-user-gesture-required`, and a key press unlocks the app's audio as a first tap would.
194
199
 
200
+ **Transitions overlap.** A dissolve, dip, slide or wipe of 0.5 s blends the last 0.5 s of one beat with the first 0.5 s of the next (ffmpeg `xfade`), so the cut is shorter than the beats added up, by every transition's length. Lengths are snapped to whole frames. Effects and music are laid where each beat starts in the cut, so they stay with their pictures. Titles leave `titles.leaveBefore` before the transition out of their beat starts, not before the cut, so no words are left showing through a dissolve. With only cuts, the clips are joined without being encoded again.
201
+
195
202
  **The cut.** `amix` takes its timeline from its first input, so that's `anullsrc` for the whole length; every input is delayed and resampled from 0. The mix is rendered to 32-bit float WAV and then muxed. Each music track is brought to −16 LUFS with one measured gain; the trailer is brought to −14 LUFS with one measured gain and limited at 0.63 (about 4 dB of headroom for the AAC encode). `loudnorm`'s dynamic mode ducked the music after every hit and isn't used.
196
203
 
197
204
  ## Rules learned the hard way
@@ -24,9 +24,49 @@ export const DEFAULTS = {
24
24
  sounds: { bundled: 'public', live: null, ignore: [], cache: 'tmp/trailer-sounds' },
25
25
  music: { level: 0.7, fade: 1.5, loudness: -16, plan: {}, plans: {}, tracks: {} },
26
26
  mix: { loudness: -14, limit: 0.63 },
27
+ // How the cut goes from one beat into the next, unless a beat says
28
+ // otherwise with its own `transition`. 'cut' joins them end to end.
29
+ transition: { type: 'cut', ms: 600 },
27
30
  check: { maxSeconds: 30, loudness: -14, tolerance: 1, truePeak: -1 },
28
31
  };
29
32
 
33
+ // The transitions a beat can come in on, and the ffmpeg xfade each one is.
34
+ // 'cut' is none: the beats are joined end to end.
35
+ export const TRANSITIONS = {
36
+ cut: null,
37
+ dissolve: 'fade',
38
+ dip: 'fadeblack',
39
+ 'dip-white': 'fadewhite',
40
+ slide: 'slideleft',
41
+ 'slide-right': 'slideright',
42
+ wipe: 'wipeleft',
43
+ };
44
+
45
+ // A transition as written in the config: a name, or { type, ms }.
46
+ export function transitionOf(value) {
47
+ if (value == null) return {};
48
+ return typeof value === 'string' ? { type: value } : value;
49
+ }
50
+
51
+ // How long the transition out of `name` into the next beat is, in seconds
52
+ // before it's snapped to frames (0 for a cut, or the last beat). The cut
53
+ // overlaps that much of this beat's end with the next one.
54
+ export function transitionOut(config, name) {
55
+ const i = config.beats.findIndex(b => b.name === name);
56
+ const next = config.beats[i + 1];
57
+ if (i < 0 || !next) return 0;
58
+ const own = transitionOf(next.transition);
59
+ const type = own.type ?? config.transition.type;
60
+ return type === 'cut' ? 0 : (own.ms ?? config.transition.ms) / 1000;
61
+ }
62
+
63
+ function checkTransition({ type, ms }, where) {
64
+ if (type != null && !(type in TRANSITIONS)) {
65
+ throw new Error(`${where} is ${type}; use one of ${Object.keys(TRANSITIONS).join(', ')}`);
66
+ }
67
+ if (ms != null && !(ms > 0)) throw new Error(`${where} needs ms above 0`);
68
+ }
69
+
30
70
  // Loads `file` (relative to `appDir`) and fills in the defaults.
31
71
  export async function loadConfig(appDir, file = 'trailer.config.mjs') {
32
72
  const full = path.resolve(appDir, file);
@@ -46,16 +86,19 @@ export function normalize(raw, appDir) {
46
86
  sounds: { ...DEFAULTS.sounds, ...raw.sounds },
47
87
  music: { ...DEFAULTS.music, ...raw.music },
48
88
  mix: { ...DEFAULTS.mix, ...raw.mix },
89
+ transition: { ...DEFAULTS.transition, ...transitionOf(raw.transition) },
49
90
  check: { ...DEFAULTS.check, ...raw.check },
50
91
  };
51
92
  if (!config.showcase || !(config.showcase.build || config.showcase.url)) {
52
93
  throw new Error('trailer.config.mjs needs showcase: { build } (a module that builds the showcase) or showcase: { url }');
53
94
  }
95
+ checkTransition(config.transition, 'transition');
54
96
  if (!Array.isArray(config.beats) || !config.beats.length) throw new Error('trailer.config.mjs has no beats');
55
97
  const names = new Set();
56
98
  for (const beat of config.beats) {
57
99
  if (!beat.name || typeof beat.play !== 'function') throw new Error('every beat needs a name and a play function');
58
100
  if (names.has(beat.name)) throw new Error(`two beats are called ${beat.name}`);
101
+ checkTransition(transitionOf(beat.transition), `${beat.name}'s transition`);
59
102
  names.add(beat.name);
60
103
  }
61
104
  return config;
data/engine/lib/cut.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { ffmpeg, loudness } from './ffmpeg.mjs';
8
- import { loadConfig, outDir, pickFormats } from './config.mjs';
8
+ import { loadConfig, outDir, pickFormats, TRANSITIONS, transitionOf } from './config.mjs';
9
9
  import { liveSounds } from './sound.mjs';
10
10
 
11
11
  // Runs of beats under the same music track. A beat's 'hush' mark stops its
@@ -45,6 +45,54 @@ export function runEnvelope(runs, i, total, fade) {
45
45
  };
46
46
  }
47
47
 
48
+ // How each beat comes in: its own `transition`, else the config's. The first
49
+ // beat has nothing to come in from. A transition overlaps the end of one beat
50
+ // with the start of the next, so its length is snapped to whole frames and
51
+ // kept shorter than either beat.
52
+ export function transitions(config, beats, fps) {
53
+ return beats.map((beat, i) => {
54
+ if (i === 0) return { type: 'cut', seconds: 0 };
55
+ const own = transitionOf(config.beats.find(b => b.name === beat.name)?.transition);
56
+ const type = own.type ?? config.transition.type;
57
+ if (type === 'cut') return { type, seconds: 0 };
58
+ const ms = own.ms ?? config.transition.ms;
59
+ const seconds = Math.round((ms / 1000) * fps) / fps;
60
+ const shortest = Math.min(beats[i - 1].seconds, beat.seconds);
61
+ if (seconds >= shortest) throw new Error(`the ${type} into ${beat.name} (${ms} ms) is as long as a beat it joins; make it shorter`);
62
+ return { type, seconds };
63
+ });
64
+ }
65
+
66
+ // Where each beat starts in the cut, and how long the cut is, once each
67
+ // transition overlaps a beat with the one before it.
68
+ export function layout(seconds, overlaps) {
69
+ let t = 0;
70
+ const starts = seconds.map((s, i) => {
71
+ t -= overlaps[i] || 0;
72
+ const start = t;
73
+ t += s;
74
+ return start;
75
+ });
76
+ return { starts, total: t };
77
+ }
78
+
79
+ // The ffmpeg filter that joins the beat clips (inputs 0…n-1) with their
80
+ // transitions into [video].
81
+ export function joinFilter(steps, starts, fps) {
82
+ const parts = steps.map((_, i) => `[${i}:v]settb=AVTB,fps=${fps},format=yuv420p,setpts=PTS-STARTPTS[b${i}]`);
83
+ let last = 'b0';
84
+ for (let i = 1; i < steps.length; i += 1) {
85
+ const out = i === steps.length - 1 ? 'video' : `j${i}`;
86
+ const { type, seconds } = steps[i];
87
+ parts.push(type === 'cut'
88
+ ? `[${last}][b${i}]concat=n=2:v=1:a=0[${out}]`
89
+ : `[${last}][b${i}]xfade=transition=${TRANSITIONS[type]}:duration=${seconds.toFixed(6)}:offset=${starts[i].toFixed(6)}[${out}]`);
90
+ last = out;
91
+ }
92
+ if (steps.length === 1) parts.push('[b0]null[video]');
93
+ return parts.join(';');
94
+ }
95
+
48
96
  // The music plan to cut with: the default `music.plan`, or a named one from
49
97
  // `music.plans`.
50
98
  export function pickPlan(config, name) {
@@ -72,7 +120,6 @@ export async function cut(appDir, opts = {}) {
72
120
  const written = [];
73
121
 
74
122
  for (const format of pickFormats(config, opts.format)) {
75
- let t = 0;
76
123
  const beats = config.beats.map(({ name }) => {
77
124
  const file = path.join(dir, `${name}-${format.name}.mp4`);
78
125
  const meta = path.join(dir, `${name}-${format.name}.json`);
@@ -80,11 +127,11 @@ export async function cut(appDir, opts = {}) {
80
127
  throw new Error(`${path.relative(process.cwd(), file)} is missing: film it first (belt trailer film --only ${name} --format ${format.name})`);
81
128
  }
82
129
  const { seconds, sounds, marks = {} } = JSON.parse(fs.readFileSync(meta, 'utf8'));
83
- const beat = { name, track: plan[name] ?? null, file, start: t, seconds, sounds, hush: marks.hush };
84
- t += seconds;
85
- return beat;
130
+ return { name, track: plan[name] ?? null, file, seconds, sounds, hush: marks.hush };
86
131
  });
87
- const total = t;
132
+ const steps = transitions(config, beats, config.fps);
133
+ const { starts, total } = layout(beats.map(b => b.seconds), steps.map(s => s.seconds));
134
+ beats.forEach((beat, i) => { beat.start = starts[i]; beat.transition = steps[i]; });
88
135
  const runs = planRuns(beats);
89
136
  if (!runs[0].track) console.warn(` ! no music under ${beats[0].name}: a silent cold open reads as a broken video`);
90
137
 
@@ -124,10 +171,18 @@ export async function cut(appDir, opts = {}) {
124
171
 
125
172
  const work = fs.mkdtempSync(path.join(dir, '.cut-'));
126
173
  try {
127
- const list = path.join(work, 'list.txt');
128
- fs.writeFileSync(list, beats.map(b => `file '${b.file.replace(/'/g, "'\\''")}'`).join('\n'));
129
174
  const video = path.join(work, 'video.mp4');
130
- ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, '-an', '-c', 'copy', video]);
175
+ if (steps.every(s => s.type === 'cut')) {
176
+ const list = path.join(work, 'list.txt');
177
+ fs.writeFileSync(list, beats.map(b => `file '${b.file.replace(/'/g, "'\\''")}'`).join('\n'));
178
+ ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, '-an', '-c', 'copy', video]);
179
+ } else {
180
+ // A transition blends two clips, so the picture is encoded again, at
181
+ // the same settings the beats were filmed with.
182
+ ffmpeg([...beats.flatMap(b => ['-i', b.file]), '-filter_complex', joinFilter(steps, starts, config.fps),
183
+ '-map', '[video]', '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '14', '-r', String(config.fps),
184
+ '-t', total.toFixed(6), video]);
185
+ }
131
186
 
132
187
  // amix takes its timeline from its first input, so that is silence
133
188
  // from 0 for the whole trailer: with music first, everything before
@@ -156,7 +211,10 @@ export async function cut(appDir, opts = {}) {
156
211
  ffmpeg(['-i', video, '-i', wav, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-t', total.toFixed(6), out]);
157
212
  fs.writeFileSync(out.replace(/\.mp4$/, '.json'), `${JSON.stringify({
158
213
  format: format.name, music: opts.music || 'default', seconds: Number(total.toFixed(3)),
159
- beats: beats.map(b => ({ name: b.name, start: Number(b.start.toFixed(3)), seconds: b.seconds, track: b.track })),
214
+ beats: beats.map(b => ({
215
+ name: b.name, start: Number(b.start.toFixed(3)), seconds: b.seconds, track: b.track,
216
+ ...(b.transition.type === 'cut' ? {} : { transition: { type: b.transition.type, seconds: Number(b.transition.seconds.toFixed(3)) } }),
217
+ })),
160
218
  sources,
161
219
  }, null, 2)}\n`);
162
220
  console.log(`${path.relative(process.cwd(), out)} ${total.toFixed(1)} s`);
data/engine/lib/film.mjs CHANGED
@@ -122,7 +122,7 @@ export async function film(appDir, opts = {}) {
122
122
  await beat.play(stage(page, cam, format));
123
123
  await page.close();
124
124
  if (!cam.frames) throw new Error(`beat ${beat.name} filmed no frames: its play() never called cam.hold, move or shoot`);
125
- await filmTitles(browser, { site, route, config, format, cues: cam.cues, frames: cam.frames, dir: titles });
125
+ await filmTitles(browser, { site, route, config, format, beat: beat.name, cues: cam.cues, frames: cam.frames, dir: titles });
126
126
 
127
127
  // The effects the beat played, for the cut to lay across the whole
128
128
  // trailer (a knockout rings on past its own beat), and mixed into the
@@ -4,6 +4,7 @@
4
4
  import path from 'node:path';
5
5
  import { freeze, step } from './clock.mjs';
6
6
  import { TITLES_PATH } from './showcase.mjs';
7
+ import { transitionOut } from './config.mjs';
7
8
 
8
9
  // The cues with a closing hide added: a title still up near the end leaves
9
10
  // `leaveBefore` seconds before the cut, so a shot never ends on words and the
@@ -15,7 +16,7 @@ export function closeTitles(cues, frames, fps, leaveBefore) {
15
16
  return [...cues, { frame: Math.max(last.frame + 1, frames - Math.round(leaveBefore * fps)), hide: true }];
16
17
  }
17
18
 
18
- export async function filmTitles(browser, { site, route, config, format, cues, frames, dir }) {
19
+ export async function filmTitles(browser, { site, route, config, format, beat, cues, frames, dir }) {
19
20
  const [width, height] = format.out;
20
21
  const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
21
22
  await page.route(`${site.origin}/**`, route);
@@ -27,7 +28,9 @@ export async function filmTitles(browser, { site, route, config, format, cues, f
27
28
  }, config.titles.endCard?.logo);
28
29
  await freeze(page);
29
30
  const frameMs = 1000 / config.fps;
30
- const all = closeTitles(cues, frames, config.fps, config.titles.leaveBefore);
31
+ // Words leave before the next beat starts to come in, not just before the
32
+ // cut: a dissolve shows the end of this beat under the next one.
33
+ const all = closeTitles(cues, frames, config.fps, config.titles.leaveBefore + transitionOut(config, beat));
31
34
  let now = 0;
32
35
  for (let i = 0; i < frames; i += 1) {
33
36
  for (const cue of all.filter(c => c.frame === i)) {
data/engine/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "belt-trailer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "description": "Films a launch trailer of a web app from its own screens and sounds",
6
6
  "type": "module",
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Belt
4
4
  module Trailer
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -13,7 +13,7 @@ description: >-
13
13
  editing of footage that wasn't filmed from the app, or vehicle trailers.
14
14
  metadata:
15
15
  author: stowzilla
16
- version: "0.1.0"
16
+ version: "0.2.0"
17
17
  ---
18
18
 
19
19
  # belt-trailer
@@ -110,6 +110,26 @@ await cam.hold(1300);
110
110
  } },
111
111
  ```
112
112
 
113
+ ## Between beats: transitions
114
+
115
+ By default beats are joined with a hard cut. That works when the next beat
116
+ opens on the framing the last one ended on. Between two different pages, or
117
+ from a close-up to a wide shot, a hard cut reads as a glitch, so set a
118
+ transition:
119
+
120
+ ```js
121
+ transition: { type: 'dissolve', ms: 500 }, // every change of beat
122
+ { name: 'end', transition: 'dip', play: … }, // into this beat only
123
+ { name: 'level', transition: 'cut', play: … }, // a hard cut here
124
+ ```
125
+
126
+ Types: `cut`, `dissolve`, `dip` (through black), `dip-white`, `slide`,
127
+ `slide-right`, `wipe`. A transition overlaps the two beats, so the cut gets
128
+ shorter by its length, and titles leave that much earlier. Add the same to
129
+ the hold after a beat's last title so it is still read for ~2 s. Keep them
130
+ short (0.4–0.6 s) and the same throughout; a different one is for a change
131
+ of mood (into the end card, say).
132
+
113
133
  ## Wide and tall
114
134
 
115
135
  The same beat films both formats: wide from a 1280×720 page, tall from a
@@ -58,6 +58,8 @@ The beat names are yours (`--only` takes them), lower-case, one word.
58
58
  - Every product shot is **the same session at a later moment**: the same
59
59
  records, people, names and numbers moving forward. Say so in the
60
60
  storyboard and name the fixture function that builds them.
61
+ - **Between pages, transition**: when the next beat is a different screen,
62
+ write the transition (usually a 0.5 s dissolve) in the Camera column.
61
63
  - **Match framing across cuts**: end a shot on the framing the next one opens
62
64
  with. Write the end framing and the next opening framing in the Camera column.
63
65
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt-trailer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -96,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
96
  - !ruby/object:Gem::Version
97
97
  version: '0'
98
98
  requirements: []
99
- rubygems_version: 4.0.24
99
+ rubygems_version: 3.6.9
100
100
  specification_version: 4
101
101
  summary: Film a launch trailer of a web app from its own screens and sounds
102
102
  test_files: []