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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +8 -1
- data/engine/lib/config.mjs +43 -0
- data/engine/lib/cut.mjs +68 -10
- data/engine/lib/film.mjs +1 -1
- data/engine/lib/titles.mjs +5 -2
- data/engine/package.json +1 -1
- data/lib/belt/trailer/version.rb +1 -1
- data/skills/belt-trailer/SKILL.md +1 -1
- data/skills/belt-trailer/references/beats.md +20 -0
- data/skills/belt-trailer/references/storyboard.md +2 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6305cc863de1b96aa6052687e9bfb058d37173d8c87a9e1bd2e2e80f2c686782
|
|
4
|
+
data.tar.gz: 29d6ad1a84d46e9e70b8118e86499c3310b7a0750d49fb0a5a8a22923c230366
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/engine/lib/config.mjs
CHANGED
|
@@ -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
|
-
|
|
84
|
-
t += seconds;
|
|
85
|
-
return beat;
|
|
130
|
+
return { name, track: plan[name] ?? null, file, seconds, sounds, hush: marks.hush };
|
|
86
131
|
});
|
|
87
|
-
const
|
|
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
|
-
|
|
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 => ({
|
|
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
|
data/engine/lib/titles.mjs
CHANGED
|
@@ -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
|
-
|
|
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
data/lib/belt/trailer/version.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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: []
|