reelkit-cli 0.4.0 → 0.6.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.
- package/README.md +3 -2
- package/package.json +3 -2
- package/skill/SKILL.md +11 -5
- package/skill/reference/beat-sync.md +54 -0
- package/skill/reference/captions.md +18 -2
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +99 -0
- package/skill/reference/kit.md +19 -1
- package/skill/reference/motion-design.md +9 -0
- package/skill/reference/references.md +11 -0
- package/skill/reference/remotion-composition.md +2 -1
- package/skill/reference/scene-treatments.md +1 -1
- package/skill/reference/scriptwriting.md +39 -5
- package/skill/reference/sound-design.md +11 -0
- package/skill/reference/styles.md +9 -0
- package/src/cli.ts +10 -4
- package/src/commands/assets.ts +46 -5
- package/src/commands/build.ts +99 -11
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- package/src/commands/plan.ts +4 -2
- package/src/commands/ref.ts +11 -3
- package/src/contract/index.ts +23 -3
- package/src/pipeline/beatsnap.ts +52 -0
- package/src/pipeline/review.ts +54 -1
- package/src/pipeline/schema.ts +8 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +31 -1
- package/src/project/music.ts +25 -0
- package/src/project/project.ts +4 -2
- package/src/project/refmeasure.ts +45 -1
- package/src/remotion/Root.tsx +4 -2
- package/src/remotion/kit/Camera.tsx +22 -0
- package/src/remotion/kit/Captions.tsx +25 -8
- package/src/remotion/kit/Carry.tsx +38 -0
- package/src/remotion/kit/Music.tsx +19 -0
- package/src/remotion/kit/Sfx.tsx +12 -6
- package/src/remotion/kit/beat.ts +23 -0
- package/src/remotion/kit/caption-groups.ts +65 -0
- package/src/remotion/kit/docs.ts +19 -1
- package/src/remotion/kit/index.ts +7 -0
- package/src/remotion/kit/media.ts +17 -0
- package/src/remotion/kit/motion-math.ts +113 -0
- package/src/remotion/kit/music-math.ts +42 -0
- package/src/render/continuity.ts +87 -0
- package/src/render/master.ts +31 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +30 -150
- package/src/testing/conformance.ts +49 -1
- package/src/testing/fake-api.ts +7 -1
package/src/remotion/kit/Sfx.tsx
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { useMedia } from "./media";
|
|
2
|
+
import { useGain, useMedia } from "./media";
|
|
3
3
|
import { Audio, Sequence } from "remotion";
|
|
4
4
|
|
|
5
5
|
// Plays one sound effect starting at a frame. at is counted from the start of the enclosing scene, or of the video
|
|
6
6
|
// when placed outside the scenes. Keep volume well under the voiceover.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
);
|
|
7
|
+
// A sound pulled from the library is levelled when it is pulled (the manifest's soundGain), and that is applied here by itself, so the same
|
|
8
|
+
// volume sounds equally loud for every file: 0.2 to 0.45 is the range for an effect.
|
|
9
|
+
export const Sfx: React.FC<{ src: string; at?: number; volume?: number }> = ({ src, at = 0, volume = 0.35 }) => {
|
|
10
|
+
const url = useMedia(src);
|
|
11
|
+
const gain = useGain(src);
|
|
12
|
+
return (
|
|
13
|
+
<Sequence from={Math.max(0, Math.round(at))} layout="none">
|
|
14
|
+
<Audio src={url} volume={volume * gain} />
|
|
15
|
+
</Sequence>
|
|
16
|
+
);
|
|
17
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Helpers for putting things on the beat. `beatFrames` is manifest.music.beatFrames: composition frames, ascending.
|
|
2
|
+
// Inside a scene, subtract the scene's startFrame to get a frame the scene's own useCurrentFrame() can use.
|
|
3
|
+
|
|
4
|
+
// The beat nearest to `frame`, or undefined when the track has no beats.
|
|
5
|
+
export function nearestBeat(beatFrames: number[], frame: number): number | undefined {
|
|
6
|
+
let best: number | undefined;
|
|
7
|
+
for (const b of beatFrames) if (best === undefined || Math.abs(b - frame) < Math.abs(best - frame)) best = b;
|
|
8
|
+
return best;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The first beat at or after `frame`, or undefined when there is none.
|
|
12
|
+
export function nextBeat(beatFrames: number[], frame: number): number | undefined {
|
|
13
|
+
return beatFrames.find((b) => b >= frame);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 0 to 1: 1 on a beat, falling to 0 over `decayFrames` (default 10) after it, and 0 before the first beat.
|
|
17
|
+
export function beatPulse(beatFrames: number[], frame: number, decayFrames = 10): number {
|
|
18
|
+
let last: number | undefined;
|
|
19
|
+
for (const b of beatFrames) { if (b <= frame) last = b; else break; }
|
|
20
|
+
if (last === undefined) return 0;
|
|
21
|
+
const x = (frame - last) / Math.max(1, decayFrames);
|
|
22
|
+
return x >= 1 ? 0 : (1 - x) * (1 - x);
|
|
23
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { WordTiming } from "../../pipeline/schema";
|
|
2
|
+
|
|
3
|
+
export type CaptionGroup = "none" | "word" | "phrase";
|
|
4
|
+
|
|
5
|
+
export const MAX_GROUP_WORDS = 6;
|
|
6
|
+
export const MAX_GROUP_CHARS = 32;
|
|
7
|
+
// A pause between two words at least this long ends a group.
|
|
8
|
+
export const PAUSE_SEC = 0.35;
|
|
9
|
+
// A comma or a dash ends a group only when it already holds this many words.
|
|
10
|
+
export const MIN_WORDS_AT_COMMA = 3;
|
|
11
|
+
|
|
12
|
+
// Sentence ends in Latin, Hebrew and Arabic text: . ! ? … ؟ ۔ ׃ and the full-width forms, possibly followed by a closing quote or bracket.
|
|
13
|
+
const SENTENCE_END = /[.!?…؟۔׃。!?]["'”’»)\]]*$/;
|
|
14
|
+
const CLAUSE_END = /[,،,;؛:–—―-]["'”’»)\]]*$/;
|
|
15
|
+
// A word that is only a dash.
|
|
16
|
+
const LONE_DASH = /^[–—―-]+$/;
|
|
17
|
+
// Small joining words: a line breaks better before one of them than after it.
|
|
18
|
+
const JOINING = new Set(["and", "or", "but", "so", "to", "of", "in", "on", "at", "for", "with", "that", "the", "a", "an", "as", "if", "then", "from", "by", "into", "than", "nor", "yet", "who", "which", "when", "while",
|
|
19
|
+
"ו", "של", "על", "את", "עם", "או", "אבל", "כי", "אם", "כדי", "אל", "מן", "גם", "ש", "עד", "לא"]);
|
|
20
|
+
|
|
21
|
+
const clean = (w: string) => w.trim();
|
|
22
|
+
const endsSentence = (w: string) => SENTENCE_END.test(clean(w));
|
|
23
|
+
const endsClause = (w: string) => CLAUSE_END.test(clean(w)) || LONE_DASH.test(clean(w));
|
|
24
|
+
const isJoining = (w: string) => JOINING.has(clean(w).toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""));
|
|
25
|
+
const chars = (ws: WordTiming[]) => ws.reduce((n, w, i) => n + clean(w.word).length + (i ? 1 : 0), 0);
|
|
26
|
+
const fits = (ws: WordTiming[]) => ws.length <= MAX_GROUP_WORDS && chars(ws) <= MAX_GROUP_CHARS;
|
|
27
|
+
|
|
28
|
+
// Index ranges [start, end) into `words`, one per caption. "word": one word each. "phrase": grouped the way the words are spoken.
|
|
29
|
+
export function captionGroups(words: WordTiming[], group: "word" | "phrase"): [number, number][] {
|
|
30
|
+
if (group === "word") return words.map((_, i) => [i, i + 1]);
|
|
31
|
+
const groups: [number, number][] = [];
|
|
32
|
+
let start = 0;
|
|
33
|
+
const gapAfter = (i: number) => (words[i + 1] ? words[i + 1]!.startSec - words[i]!.endSec : Infinity);
|
|
34
|
+
for (let i = 0; i < words.length; i++) {
|
|
35
|
+
const current = words.slice(start, i + 1);
|
|
36
|
+
if (!fits(current) && current.length > 1) {
|
|
37
|
+
// Too long: break before the new word, or a little earlier when that is after a comma or before a joining word.
|
|
38
|
+
let at = i;
|
|
39
|
+
for (let k = i - 1; k >= Math.max(start + 2, i - 2); k--) {
|
|
40
|
+
if (endsClause(words[k - 1]!.word) || isJoining(words[k]!.word)) { at = k; break; }
|
|
41
|
+
}
|
|
42
|
+
groups.push([start, at]);
|
|
43
|
+
start = at;
|
|
44
|
+
}
|
|
45
|
+
const size = i + 1 - start;
|
|
46
|
+
const ends = endsSentence(words[i]!.word) || (endsClause(words[i]!.word) && size >= MIN_WORDS_AT_COMMA) || gapAfter(i) >= PAUSE_SEC;
|
|
47
|
+
if (ends || i === words.length - 1) { groups.push([start, i + 1]); start = i + 1; }
|
|
48
|
+
}
|
|
49
|
+
// A lone word left at the end of a sentence reads better with the words before it, when they run on without a pause and it still fits.
|
|
50
|
+
const merged: [number, number][] = [];
|
|
51
|
+
for (const g of groups) {
|
|
52
|
+
const prev = merged[merged.length - 1];
|
|
53
|
+
if (prev && g[1] - g[0] === 1 && !endsSentence(words[prev[1] - 1]!.word) && words[g[0]]!.startSec - words[g[0] - 1]!.endSec < PAUSE_SEC && fits(words.slice(prev[0], g[1]))) prev[1] = g[1];
|
|
54
|
+
else merged.push([...g]);
|
|
55
|
+
}
|
|
56
|
+
return merged;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The group whose words are on screen at `t` seconds: the one holding the last word that has started (the first group before any has).
|
|
60
|
+
export function groupAt(words: WordTiming[], groups: [number, number][], t: number): { index: number; active: number } {
|
|
61
|
+
let active = 0;
|
|
62
|
+
for (let i = 0; i < words.length; i++) if (words[i]!.startSec <= t) active = i;
|
|
63
|
+
const index = Math.max(0, groups.findIndex(([a, b]) => active >= a && active < b));
|
|
64
|
+
return { index, active };
|
|
65
|
+
}
|
package/src/remotion/kit/docs.ts
CHANGED
|
@@ -13,9 +13,10 @@ LowerThird { title: string; subtitle?: string; accent?: string }
|
|
|
13
13
|
Name/label strip that slides in from the left, low on the screen.
|
|
14
14
|
<LowerThird title="Step 1" subtitle="Connect your account" />
|
|
15
15
|
|
|
16
|
-
Captions { words: WordTiming[]; mode?: "highlight" | "pop" | "karaoke"; highlight?: string; color?: string; perLine?: number; uppercase?: boolean; bottom?: number; face?: string; rtl?: boolean }
|
|
16
|
+
Captions { words: WordTiming[]; mode?: "highlight" | "pop" | "karaoke"; highlight?: string; color?: string; perLine?: number; group?: "none" | "word" | "phrase"; uppercase?: boolean; bottom?: number; face?: string; rtl?: boolean }
|
|
17
17
|
Word-timed captions near the bottom. Pass the scene's words from the manifest. Pick one mode for the whole video:
|
|
18
18
|
"highlight" (a line, spoken word coloured), "pop" (1-3 words popping in as spoken), "karaoke" (a line filling with colour).
|
|
19
|
+
group decides which words share the screen: "word" is one word at a time, "phrase" groups words as they are spoken (a group ends at a sentence end, at a comma after three words, at a pause of 0.35 s, and never holds more than 6 words or about 32 characters), "none" draws nothing. Pass the plan's choice straight through: group={manifest.captions}. Without group, perLine counts the words as before.
|
|
19
20
|
bottom is the distance from the bottom edge as a fraction of the height (default 0.16).
|
|
20
21
|
For Hebrew narration pass face={font("heebo")} and rtl.
|
|
21
22
|
<Captions words={s.words} mode="pop" highlight={palette.hero} uppercase />
|
|
@@ -58,6 +59,14 @@ WordReveal { text: string; delay?: number; per?: number; highlight?: string; hig
|
|
|
58
59
|
Headline that rises in word by word from behind a mask. highlight colours one word.
|
|
59
60
|
<WordReveal text="Stretch first, phone second" highlight="first" highlightColor={palette.hero} style={{ fontFamily: fonts.display, fontWeight: 800, fontSize: width * 0.09, color: palette.ink }} />
|
|
60
61
|
|
|
62
|
+
Carry { keys: { frame: number; x: number; y: number; width: number; height: number; radius?: number; opacity?: number; rotate?: number }[]; lead?: number; stiffness?: number; damping?: number; children | (box) => children }
|
|
63
|
+
One element held through several scenes. Place it once, beside the SceneFrames and above them, so it is still on screen when a scene changes. Each key says where the box must have arrived by an absolute composition frame: x, y are its centre and width, height are fractions of the frame (0 to 1); radius is the corner radius as a fraction of the frame width; rotate is in degrees. The move toward a key starts lead frames before the key's frame (default 12, or at the previous key's frame when the keys are closer) on a spring (default springs.smooth) and lands on the key's frame exactly; between moves the box holds. Before the first key nothing is drawn unless that key sets an opacity; after the last the box holds. Children fill the box; a function child gets { x, y, width, height, radius, opacity, rotate, widthPx, heightPx } for the current moment, so the content can change as the box does.
|
|
64
|
+
<Carry keys={[{ frame: a.startFrame + 6, x: 0.5, y: 0.45, width: 0.8, height: 0.3 }, { frame: b.startFrame, x: 0.5, y: 0.5, width: 1, height: 1, radius: 0 }]}>{(box) => <Card compact={box.width < 0.5} />}</Carry>
|
|
65
|
+
|
|
66
|
+
Camera { keys: { frame: number; x?: number; y?: number; zoom?: number; rotate?: number }[]; drift?: number; lead?: number; stiffness?: number; damping?: number; children }
|
|
67
|
+
Moves the whole picture. Wrap all the scenes for one camera that never cuts, or wrap one scene's content. x, y are the point of the content, as fractions, that sits at the middle of the frame (default 0.5, 0.5); zoom 1 shows the content as it fits; rotate is in degrees. A key that leaves a value out keeps the one before it. The timing rule is the same as Carry's: the move toward a key starts lead frames before its frame and lands on it. drift is a very slow continuous push, as a fraction of the zoom per second (0.02 is two percent a second), so a held shot is never perfectly still.
|
|
68
|
+
<Camera keys={[{ frame: 0, zoom: 1 }, { frame: b.startFrame, x: 0.7, y: 0.4, zoom: 4 }]} drift={0.01}>{scenes}</Camera>
|
|
69
|
+
|
|
61
70
|
fonts { display: string; body: string }
|
|
62
71
|
Loaded font families. Use fonts.display (weights 600-800) for headlines and numbers, fonts.body for supporting text. Never leave hero text on a default font.
|
|
63
72
|
|
|
@@ -98,8 +107,17 @@ ScreenOverlay { src: string; durationSec?: number; opacity?: number }
|
|
|
98
107
|
|
|
99
108
|
Sfx { src: string; at?: number; volume?: number }
|
|
100
109
|
Plays one sound effect from the shared library, starting at the frame given by "at", counted from the start of the enclosing scene. Pull one with: reelkit assets search "<description>" --kind sfx, then reelkit assets pull <id>.
|
|
110
|
+
A pulled sound is levelled when it is pulled (the manifest's soundGain), and Sfx and Music apply that by themselves: the same volume sounds equally loud for every file. Nothing to pass.
|
|
101
111
|
<Sfx src={urls["assets/lib/<id>/clip.mp3"]} at={10} volume={0.35} />
|
|
102
112
|
|
|
113
|
+
Music { src: string; volume?: number; duckTo?: number }
|
|
114
|
+
The video's one music track (pull it with: reelkit assets pull <id> --music). Place it once, outside the scenes. It sits at duckTo (default 0.12) while the voiceover is heard and comes up to volume (default 0.5) in gaps longer than 0.6 s, loops if the video is longer than the track, and fades out over the last second. It follows the manifest, so nothing else is passed.
|
|
115
|
+
<Music src={urls[manifest.music.key]} />
|
|
116
|
+
|
|
117
|
+
nearestBeat(beatFrames, frame): number | undefined nextBeat(beatFrames, frame): number | undefined beatPulse(beatFrames, frame, decayFrames = 10): number
|
|
118
|
+
Beat helpers; beatFrames is manifest.music.beatFrames (composition frames). nearestBeat is the closest beat, nextBeat the first at or after the frame, beatPulse a number from 0 to 1 that is 1 on a beat and falls to 0 after it. Inside a scene, subtract s.startFrame from a beat to get a frame for that scene's own clock.
|
|
119
|
+
const b = nextBeat(manifest.music?.beatFrames ?? [], s.startFrame + 20) ?? s.startFrame + 20; // <Entrance delay={b - s.startFrame}>
|
|
120
|
+
|
|
103
121
|
Voiceover { src: string; volume?: number }
|
|
104
122
|
The scene's narration audio. One per narrated scene, inside its SceneFrame. Guard it with s.voiceoverKey so a scene without a recording still renders.
|
|
105
123
|
{s.voiceoverKey ? <Voiceover src={urls[s.voiceoverKey]} /> : null}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
export { Camera } from "./Camera";
|
|
1
2
|
export { Captions } from "./Captions";
|
|
3
|
+
export { Carry } from "./Carry";
|
|
4
|
+
export type { CarryBox } from "./Carry";
|
|
2
5
|
export { ClipLayer } from "./ClipLayer";
|
|
3
6
|
export { Counter } from "./Counter";
|
|
4
7
|
export { Entrance, WordReveal } from "./Entrance";
|
|
@@ -13,10 +16,14 @@ export { LowerThird } from "./LowerThird";
|
|
|
13
16
|
export { SceneFrame } from "./SceneFrame";
|
|
14
17
|
export { ScreenOverlay } from "./ScreenOverlay";
|
|
15
18
|
export { useMedia } from "./media";
|
|
19
|
+
export { Music } from "./Music";
|
|
20
|
+
export { beatPulse, nearestBeat, nextBeat } from "./beat";
|
|
16
21
|
export { Sfx } from "./Sfx";
|
|
17
22
|
export { TitleCard } from "./TitleCard";
|
|
18
23
|
export { Voiceover } from "./Voiceover";
|
|
19
24
|
export type { VideoProps } from "../types";
|
|
20
25
|
export { ease, font, fonts, palettes, springs } from "./theme";
|
|
21
26
|
export type { CaptionMode } from "./Captions";
|
|
27
|
+
export type { CaptionGroup } from "./caption-groups";
|
|
28
|
+
export type { BoxKey, CameraKey } from "./motion-math";
|
|
22
29
|
export type { KitFont, Palette } from "./theme";
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { createContext, useContext } from "react";
|
|
2
|
+
import type { AssetManifest } from "../../pipeline/schema";
|
|
2
3
|
|
|
3
4
|
// The project's media links, keyed by project path (assets/...). Root provides them, so a kit component works whether it is
|
|
4
5
|
// given a link (urls[key]) or just the project path (key).
|
|
5
6
|
export const UrlsContext = createContext<Record<string, string>>({});
|
|
7
|
+
// The manifest of the video being made, provided by Root, so Music can duck under the voice and Sfx and Music can be levelled without being told.
|
|
8
|
+
export const ManifestContext = createContext<AssetManifest | undefined>(undefined);
|
|
6
9
|
|
|
7
10
|
export function useMedia(src: string): string {
|
|
8
11
|
const urls = useContext(UrlsContext);
|
|
9
12
|
return urls[src] ?? src;
|
|
10
13
|
}
|
|
14
|
+
|
|
15
|
+
export const useManifest = () => useContext(ManifestContext);
|
|
16
|
+
|
|
17
|
+
// The linear gain that levels a sound, from the manifest's soundGain. The sound may be named by its project path or by its link.
|
|
18
|
+
export function gainFor(gains: Record<string, number> | undefined, urls: Record<string, string>, src: string): number {
|
|
19
|
+
if (!gains) return 1;
|
|
20
|
+
if (gains[src] !== undefined) return gains[src]!;
|
|
21
|
+
const path = Object.keys(gains).find((k) => urls[k] === src);
|
|
22
|
+
return path ? gains[path]! : 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useGain(src: string): number {
|
|
26
|
+
return gainFor(useContext(ManifestContext)?.soundGain, useContext(UrlsContext), src);
|
|
27
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The arithmetic behind Carry and Camera, with no React in it, so that it can be tested without rendering.
|
|
2
|
+
// Everything here is a pure function of the frame: the same frame always gives the same picture, which a renderer needs
|
|
3
|
+
// because it draws frames out of order and in parallel.
|
|
4
|
+
import { springs } from "./theme";
|
|
5
|
+
|
|
6
|
+
// How many frames before a key's frame the move toward it begins.
|
|
7
|
+
export const DEFAULT_LEAD = 12;
|
|
8
|
+
|
|
9
|
+
export type SpringConfig = { stiffness: number; damping: number };
|
|
10
|
+
|
|
11
|
+
// The unit step response of a mass-spring-damper with mass 1, at `seconds` after the push.
|
|
12
|
+
function step({ stiffness, damping }: SpringConfig, seconds: number): number {
|
|
13
|
+
const k = Math.max(1e-6, stiffness);
|
|
14
|
+
const w = Math.sqrt(k);
|
|
15
|
+
const z = Math.max(0, damping) / (2 * w);
|
|
16
|
+
if (z < 1) {
|
|
17
|
+
const wd = w * Math.sqrt(1 - z * z);
|
|
18
|
+
return 1 - Math.exp(-z * w * seconds) * (Math.cos(wd * seconds) + ((z * w) / wd) * Math.sin(wd * seconds));
|
|
19
|
+
}
|
|
20
|
+
if (z === 1) return 1 - (1 + w * seconds) * Math.exp(-w * seconds);
|
|
21
|
+
const root = Math.sqrt(z * z - 1);
|
|
22
|
+
const r1 = -w * (z - root), r2 = -w * (z + root);
|
|
23
|
+
return 1 - (r2 * Math.exp(r1 * seconds) - r1 * Math.exp(r2 * seconds)) / (r2 - r1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// How far a move has got, from 0 to 1, when t (0..1) of its time has passed. The move lasts `frames` frames and follows a spring,
|
|
27
|
+
// then a small correction, shaped like a smoothstep, closes the little that a real spring still has left at that moment, so that a
|
|
28
|
+
// move always arrives on its frame exactly and does not jump when it does.
|
|
29
|
+
export function travel(t: number, fps: number, config: SpringConfig = springs.smooth, frames = DEFAULT_LEAD): number {
|
|
30
|
+
if (!(t > 0)) return 0;
|
|
31
|
+
if (t >= 1) return 1;
|
|
32
|
+
const left = 1 - step(config, frames / fps);
|
|
33
|
+
return step(config, (t * frames) / fps) + left * (3 * t * t - 2 * t * t * t);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Orders keys by frame. A key that shares its frame with another keeps its place in the list, so the later one wins.
|
|
37
|
+
export const sortKeys = <K extends { frame: number }>(keys: readonly K[]): K[] => [...keys].sort((a, b) => a.frame - b.frame);
|
|
38
|
+
|
|
39
|
+
type Timing = { lead?: number; stiffness?: number; damping?: number };
|
|
40
|
+
const springOf = (o: Timing): SpringConfig => ({ stiffness: o.stiffness ?? springs.smooth.stiffness, damping: o.damping ?? springs.smooth.damping });
|
|
41
|
+
|
|
42
|
+
// Where a value stands at `frame` among sorted keys. The move toward key N starts `lead` frames before key N's frame (or at key N-1's
|
|
43
|
+
// frame when they are closer than that) and ends on key N's frame; before and after a move the value holds. It returns the two keys
|
|
44
|
+
// the frame is between and how far along the move is (from === to when it is holding).
|
|
45
|
+
function locate<K extends { frame: number }>(sorted: K[], frame: number, fps: number, o: Timing): { from: K; to: K; p: number } {
|
|
46
|
+
const lead = Math.max(1, o.lead ?? DEFAULT_LEAD);
|
|
47
|
+
let from = sorted[0]!;
|
|
48
|
+
for (let j = 1; j < sorted.length; j++) {
|
|
49
|
+
const to = sorted[j]!;
|
|
50
|
+
if (frame >= to.frame) { from = to; continue; }
|
|
51
|
+
const start = Math.max(to.frame - lead, from.frame);
|
|
52
|
+
if (frame < start) return { from, to: from, p: 0 };
|
|
53
|
+
return { from, to, p: travel((frame - start) / (to.frame - start), fps, springOf(o), to.frame - start) };
|
|
54
|
+
}
|
|
55
|
+
return { from, to: from, p: 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const mix = (a: number, b: number, p: number) => a + (b - a) * p;
|
|
59
|
+
|
|
60
|
+
export type BoxKey = { frame: number; x: number; y: number; width: number; height: number; radius?: number; opacity?: number; rotate?: number };
|
|
61
|
+
export type Box = { x: number; y: number; width: number; height: number; radius: number; opacity: number; rotate: number };
|
|
62
|
+
|
|
63
|
+
const fullBox = (k: BoxKey): Box => ({ x: k.x, y: k.y, width: k.width, height: k.height, radius: k.radius ?? 0, opacity: k.opacity ?? 1, rotate: k.rotate ?? 0 });
|
|
64
|
+
|
|
65
|
+
// The box of a Carry at `frame`, or null when nothing should be drawn: before the first key the box is not there, unless that key
|
|
66
|
+
// sets an opacity, which says it is there from the start.
|
|
67
|
+
export function boxAt(keys: readonly BoxKey[], frame: number, fps: number, timing: Timing = {}): Box | null {
|
|
68
|
+
if (!keys.length) return null;
|
|
69
|
+
const sorted = sortKeys(keys);
|
|
70
|
+
const first = sorted[0]!;
|
|
71
|
+
if (frame < first.frame) return first.opacity === undefined ? null : fullBox(first);
|
|
72
|
+
const { from, to, p } = locate(sorted, frame, fps, timing);
|
|
73
|
+
const a = fullBox(from), b = fullBox(to);
|
|
74
|
+
// A spring can overshoot; a size below nothing or an opacity outside 0..1 would be nonsense, so they are held to their range.
|
|
75
|
+
return {
|
|
76
|
+
x: mix(a.x, b.x, p), y: mix(a.y, b.y, p), width: Math.max(0, mix(a.width, b.width, p)), height: Math.max(0, mix(a.height, b.height, p)),
|
|
77
|
+
radius: Math.max(0, mix(a.radius, b.radius, p)), opacity: Math.min(1, Math.max(0, mix(a.opacity, b.opacity, p))), rotate: mix(a.rotate, b.rotate, p),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type CameraKey = { frame: number; x?: number; y?: number; zoom?: number; rotate?: number };
|
|
82
|
+
export type CameraView = { x: number; y: number; zoom: number; rotate: number };
|
|
83
|
+
|
|
84
|
+
// A key that leaves a value out keeps the one before it; the first key starts from the centre, at zoom 1, level.
|
|
85
|
+
export function resolveCameraKeys(keys: readonly CameraKey[]): (CameraView & { frame: number })[] {
|
|
86
|
+
let last: CameraView = { x: 0.5, y: 0.5, zoom: 1, rotate: 0 };
|
|
87
|
+
return sortKeys(keys).map((k) => {
|
|
88
|
+
last = { x: k.x ?? last.x, y: k.y ?? last.y, zoom: k.zoom ?? last.zoom, rotate: k.rotate ?? last.rotate };
|
|
89
|
+
return { frame: k.frame, ...last };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The camera at `frame`. drift is a slow push on top of the keys, as a fraction of the zoom per second, counted from frame 0, so that
|
|
94
|
+
// a shot that is held is never perfectly still.
|
|
95
|
+
export function cameraAt(keys: readonly CameraKey[], frame: number, fps: number, o: Timing & { drift?: number } = {}): CameraView {
|
|
96
|
+
const resolved = resolveCameraKeys(keys);
|
|
97
|
+
if (!resolved.length) return { x: 0.5, y: 0.5, zoom: 1 * (1 + (o.drift ?? 0) * Math.max(0, frame) / fps), rotate: 0 };
|
|
98
|
+
const { from, to, p } = locate(resolved, frame, fps, o);
|
|
99
|
+
return {
|
|
100
|
+
x: mix(from.x, to.x, p), y: mix(from.y, to.y, p), rotate: mix(from.rotate, to.rotate, p),
|
|
101
|
+
zoom: Math.max(0.01, mix(from.zoom, to.zoom, p)) * (1 + (o.drift ?? 0) * (Math.max(0, frame) / fps)),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const px = (n: number) => `${Math.round(n * 1000) / 1000}px`;
|
|
106
|
+
|
|
107
|
+
// The CSS that puts the point (x, y) of the content in the middle of a frame of w by h pixels, scaled and turned about that point.
|
|
108
|
+
export function cameraTransform(c: CameraView, w: number, h: number): { origin: string; transform: string } {
|
|
109
|
+
return {
|
|
110
|
+
origin: `${Math.round(c.x * 10000) / 100}% ${Math.round(c.y * 10000) / 100}%`,
|
|
111
|
+
transform: `translate(${px((0.5 - c.x) * w)}, ${px((0.5 - c.y) * h)}) rotate(${Math.round(c.rotate * 1000) / 1000}deg) scale(${Math.round(c.zoom * 10000) / 10000})`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { WordTiming } from "../../pipeline/schema";
|
|
2
|
+
|
|
3
|
+
export const DUCK_PAD_SEC = 0.25;
|
|
4
|
+
export const DUCK_RAMP_SEC = 0.2;
|
|
5
|
+
// A gap between words at least this long lets the music come back up; a shorter one keeps it down so it does not pump.
|
|
6
|
+
export const DUCK_GAP_SEC = 0.6;
|
|
7
|
+
export const FADE_OUT_SEC = 1;
|
|
8
|
+
|
|
9
|
+
export type SpeechScene = { startFrame: number; words: WordTiming[] };
|
|
10
|
+
|
|
11
|
+
// The stretches of the video, in seconds, during which a voice is heard (with the padding around each word); words closer than
|
|
12
|
+
// DUCK_GAP_SEC are one stretch.
|
|
13
|
+
export function speechSpans(scenes: SpeechScene[], fps: number): [number, number][] {
|
|
14
|
+
const words = scenes.flatMap((s) => s.words.map((w) => [s.startFrame / fps + w.startSec, s.startFrame / fps + w.endSec] as const)).sort((a, b) => a[0] - b[0]);
|
|
15
|
+
const spans: [number, number][] = [];
|
|
16
|
+
for (const [start, end] of words) {
|
|
17
|
+
const last = spans[spans.length - 1];
|
|
18
|
+
if (last && start - (last[1] - DUCK_PAD_SEC) <= DUCK_GAP_SEC) last[1] = Math.max(last[1], end + DUCK_PAD_SEC);
|
|
19
|
+
else spans.push([start - DUCK_PAD_SEC, end + DUCK_PAD_SEC]);
|
|
20
|
+
}
|
|
21
|
+
return spans;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// How far down the music is at `t` seconds, 0 (not at all) to 1 (fully): full inside a stretch, ramping over DUCK_RAMP_SEC just outside its edges.
|
|
25
|
+
function duckAmount(spans: [number, number][], t: number): number {
|
|
26
|
+
let amount = 0;
|
|
27
|
+
for (const [a, b] of spans) {
|
|
28
|
+
const edge = Math.min((t - a) / DUCK_RAMP_SEC + 1, (b - t) / DUCK_RAMP_SEC + 1);
|
|
29
|
+
amount = Math.max(amount, Math.max(0, Math.min(1, edge)));
|
|
30
|
+
}
|
|
31
|
+
return amount;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The music's volume at a composition frame: `volume` in gaps, `duckTo` under a voice, and a fade to nothing over the last second.
|
|
35
|
+
// `gain` is the track's levelling (linear). Looping needs no handling here: the frame is the composition's, not the track's.
|
|
36
|
+
export function musicVolume(frame: number, o: { fps: number; totalFrames: number; volume: number; duckTo: number; spans: [number, number][]; gain?: number }): number {
|
|
37
|
+
const t = frame / o.fps;
|
|
38
|
+
const duck = duckAmount(o.spans, t);
|
|
39
|
+
const base = o.volume + (Math.min(o.duckTo, o.volume) - o.volume) * duck;
|
|
40
|
+
const fade = Math.max(0, Math.min(1, (o.totalFrames - frame) / (FADE_OUT_SEC * o.fps)));
|
|
41
|
+
return base * fade * (o.gain ?? 1);
|
|
42
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Did anything on screen survive a scene change? The two frames on either side of a change are reduced to a small grey picture, an edge
|
|
2
|
+
// map is made of each (a smooth background has almost no edges, content has many), and the share of the earlier frame's edges that are
|
|
3
|
+
// still there in the later one says whether something was carried across. Everything here works on plain pixel arrays.
|
|
4
|
+
export const SAMPLE_WIDTH = 128;
|
|
5
|
+
const EDGE_THRESHOLD = 16;
|
|
6
|
+
// An element that moves a little between the two frames still counts as the same one, so each edge is widened by this many pixels.
|
|
7
|
+
const DILATE_RADIUS = 3;
|
|
8
|
+
export const CARRIED_AT = 0.25;
|
|
9
|
+
// Below this share of the frame being edges there is nothing to carry.
|
|
10
|
+
const MIN_EDGE_SHARE = 0.002;
|
|
11
|
+
|
|
12
|
+
export type Gray = ArrayLike<number>;
|
|
13
|
+
export type BoundaryKind = "carried" | "cut" | "empty";
|
|
14
|
+
|
|
15
|
+
// A pixel is an edge where the brightness steps by more than the threshold to the pixel on its right or below it.
|
|
16
|
+
export function edgeMap(gray: Gray, w: number, h: number, threshold = EDGE_THRESHOLD): Uint8Array {
|
|
17
|
+
const out = new Uint8Array(w * h);
|
|
18
|
+
for (let y = 0; y < h; y++) {
|
|
19
|
+
for (let x = 0; x < w; x++) {
|
|
20
|
+
const i = y * w + x;
|
|
21
|
+
const gx = x + 1 < w ? Math.abs(gray[i + 1]! - gray[i]!) : 0;
|
|
22
|
+
const gy = y + 1 < h ? Math.abs(gray[i + w]! - gray[i]!) : 0;
|
|
23
|
+
if (gx + gy > threshold) out[i] = 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The share of the frame's height, from the bottom, where the kit's captions can sit. Captions default to a baseline 16% up, and
|
|
30
|
+
// their lines are as tall as 0.075 of the width at most, so the strip covers the baseline, two lines and some room, and never more than
|
|
31
|
+
// 40% of the frame, which a wide frame would otherwise reach.
|
|
32
|
+
export function captionBand(w: number, h: number): number {
|
|
33
|
+
return Math.min(0.4, 0.16 + 2 * 0.075 * 1.15 * (w / h) + 0.03);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const clearBottom = (map: Uint8Array, w: number, h: number, share: number) => {
|
|
37
|
+
map.fill(0, Math.floor(h * (1 - share)) * w);
|
|
38
|
+
return map;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function dilate(map: Uint8Array, w: number, h: number, radius: number): Uint8Array {
|
|
42
|
+
// Two passes, along the rows and then down the columns, give the square neighbourhood without visiting all of it for each pixel.
|
|
43
|
+
const rows = new Uint8Array(w * h);
|
|
44
|
+
for (let y = 0; y < h; y++) {
|
|
45
|
+
for (let x = 0; x < w; x++) {
|
|
46
|
+
if (!map[y * w + x]) continue;
|
|
47
|
+
for (let k = Math.max(0, x - radius); k <= Math.min(w - 1, x + radius); k++) rows[y * w + k] = 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const out = new Uint8Array(w * h);
|
|
51
|
+
for (let y = 0; y < h; y++) {
|
|
52
|
+
for (let x = 0; x < w; x++) {
|
|
53
|
+
if (!rows[y * w + x]) continue;
|
|
54
|
+
for (let k = Math.max(0, y - radius); k <= Math.min(h - 1, y + radius); k++) out[k * w + x] = 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function compareFrames(earlier: Gray, later: Gray, w: number, h: number): { survived: number; edges: number; kind: BoundaryKind } {
|
|
61
|
+
const band = captionBand(w, h);
|
|
62
|
+
const before = clearBottom(edgeMap(earlier, w, h), w, h, band);
|
|
63
|
+
const after = dilate(clearBottom(edgeMap(later, w, h), w, h, band), w, h, DILATE_RADIUS);
|
|
64
|
+
let edges = 0, kept = 0;
|
|
65
|
+
for (let i = 0; i < before.length; i++) if (before[i]) { edges++; if (after[i]) kept++; }
|
|
66
|
+
if (edges < MIN_EDGE_SHARE * w * Math.floor(h * (1 - band))) return { survived: 0, edges, kind: "empty" };
|
|
67
|
+
const survived = kept / edges;
|
|
68
|
+
return { survived, edges, kind: survived >= CARRIED_AT ? "carried" : "cut" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type Boundary = { from: string; to: string; survived: number; kind: BoundaryKind };
|
|
72
|
+
|
|
73
|
+
// The score is carried over carried plus cut: a change with nothing on screen to compare says nothing either way.
|
|
74
|
+
export function summariseContinuity(boundaries: Boundary[]): { score: number | null; lines: string[] } {
|
|
75
|
+
const carried = boundaries.filter((b) => b.kind === "carried").length;
|
|
76
|
+
const cut = boundaries.filter((b) => b.kind === "cut");
|
|
77
|
+
const counted = carried + cut.length;
|
|
78
|
+
const empty = boundaries.length - counted;
|
|
79
|
+
if (!counted) return { score: null, lines: ["No scene change could be measured: the picture is nearly empty at the end of every scene."] };
|
|
80
|
+
return {
|
|
81
|
+
score: carried / counted,
|
|
82
|
+
lines: [
|
|
83
|
+
`${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}.`,
|
|
84
|
+
...cut.map((b) => `${b.from} → ${b.to}: nothing carries over. Keep one element on screen through the change and move it into the next scene (see reference/continuity.md).`),
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { loudnessLufs, tool } from "../project/refmeasure";
|
|
3
|
+
import { measureLoudnorm } from "../project/loudness";
|
|
4
|
+
|
|
5
|
+
// The loudness a finished video is brought to: -14 LUFS integrated, the level the big video platforms play at, with the loudest instant
|
|
6
|
+
// kept under -1 dBTP so that re-encoding on upload cannot clip.
|
|
7
|
+
export const MASTER_LUFS = -14;
|
|
8
|
+
export const MASTER_TRUE_PEAK_DB = -1;
|
|
9
|
+
|
|
10
|
+
export type Mastered = { inputLufs: number; outputLufs: number };
|
|
11
|
+
// What a test may replace: the whole pass.
|
|
12
|
+
export type Master = (path: string) => Promise<Mastered>;
|
|
13
|
+
|
|
14
|
+
// Two-pass loudnorm: the first pass measures, the second applies exactly the gain that measurement asks for (linear, so the dynamics stay).
|
|
15
|
+
// The video stream is copied untouched. The result replaces the file only when everything worked; otherwise the original stays as it was.
|
|
16
|
+
export const masterLoudness: Master = async (path) => {
|
|
17
|
+
const first = await measureLoudnorm(path, { i: MASTER_LUFS, tp: MASTER_TRUE_PEAK_DB });
|
|
18
|
+
const inputLufs = Number(first?.input_i);
|
|
19
|
+
if (!first || !Number.isFinite(inputLufs)) throw new Error("the video has no sound to measure");
|
|
20
|
+
const out = path.replace(/(\.[^./]+)?$/, ".mastered$1");
|
|
21
|
+
const filter = `loudnorm=I=${MASTER_LUFS}:TP=${MASTER_TRUE_PEAK_DB}:LRA=11:measured_I=${first.input_i}:measured_TP=${first.input_tp}:measured_LRA=${first.input_lra}:measured_thresh=${first.input_thresh}:offset=${first.target_offset}:linear=true`;
|
|
22
|
+
try {
|
|
23
|
+
await tool("ffmpeg", ["-v", "error", "-y", "-i", path, "-c:v", "copy", "-af", filter, "-ar", "48000", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", out]);
|
|
24
|
+
const outputLufs = await loudnessLufs(out);
|
|
25
|
+
if (outputLufs === undefined) throw new Error("the mastered file has no readable loudness");
|
|
26
|
+
renameSync(out, path);
|
|
27
|
+
return { inputLufs: Math.round(inputLufs * 10) / 10, outputLufs };
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(out, { force: true });
|
|
30
|
+
}
|
|
31
|
+
};
|