reelkit-cli 0.6.0 → 0.10.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 +5 -3
- package/package.json +52 -9
- package/skill/SKILL.md +40 -19
- package/skill/THIRD_PARTY.md +104 -2
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/art-styles.md +70 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +25 -19
- package/skill/reference/brand-motion.md +62 -0
- package/skill/reference/captions.md +11 -5
- package/skill/reference/clips.md +3 -3
- package/skill/reference/component-authoring.md +1 -1
- package/skill/reference/continuity.md +26 -7
- package/skill/reference/delivery-review.md +40 -0
- package/skill/reference/hebrew-rtl.md +3 -4
- package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +18 -11
- package/skill/reference/kit.md +144 -12
- package/skill/reference/launch-film.md +194 -0
- package/skill/reference/motion-design.md +18 -16
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/sound-design.md +34 -12
- package/skill/reference/studio-editing.md +55 -0
- package/skill/reference/styles.md +9 -6
- package/skill/reference/three-d.md +135 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +18 -7
- package/src/commands/assets.ts +314 -30
- package/src/commands/build.ts +148 -35
- package/src/commands/init.ts +1 -1
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +5 -3
- package/src/hyperframes/Root.tsx +1 -0
- package/src/hyperframes/fonts.ts +54 -0
- package/src/hyperframes/frame.tsx +46 -0
- package/src/hyperframes/host.tsx +38 -0
- package/src/hyperframes/kit/Assemble3D.tsx +92 -0
- package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
- package/src/hyperframes/kit/BrowserFrame.tsx +83 -0
- package/src/{remotion → hyperframes}/kit/Camera.tsx +7 -5
- package/src/{remotion → hyperframes}/kit/Captions.tsx +34 -18
- package/src/hyperframes/kit/Card3D.tsx +211 -0
- package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
- package/src/hyperframes/kit/ChapterFrame.tsx +68 -0
- package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
- package/src/hyperframes/kit/CounterRoll.tsx +75 -0
- package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
- package/src/hyperframes/kit/GlassPanel.tsx +43 -0
- package/src/hyperframes/kit/Grounds.tsx +177 -0
- package/src/hyperframes/kit/Headline.tsx +97 -0
- package/src/hyperframes/kit/Hero3D.tsx +197 -0
- package/src/hyperframes/kit/HudOverlay.tsx +52 -0
- package/src/hyperframes/kit/ImageLayers.tsx +48 -0
- package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
- package/src/hyperframes/kit/Music.tsx +19 -0
- package/src/hyperframes/kit/NamedCursor.tsx +54 -0
- package/src/hyperframes/kit/Orbit3D.tsx +49 -0
- package/src/hyperframes/kit/Particles3D.tsx +74 -0
- package/src/hyperframes/kit/Place.tsx +12 -0
- package/src/hyperframes/kit/PromptBox.tsx +84 -0
- package/src/hyperframes/kit/Scene3D.tsx +70 -0
- package/src/hyperframes/kit/SceneFrame.tsx +96 -0
- package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
- package/src/hyperframes/kit/SoundCues.tsx +22 -0
- package/src/hyperframes/kit/TerminalLog.tsx +98 -0
- package/src/hyperframes/kit/Text3D.tsx +78 -0
- package/src/hyperframes/kit/TextOnImage.tsx +41 -0
- package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
- package/src/hyperframes/kit/Warp3D.tsx +59 -0
- package/src/hyperframes/kit/bg-math.ts +179 -0
- package/src/hyperframes/kit/brand-transform.ts +25 -0
- package/src/{remotion → hyperframes}/kit/caption-groups.ts +7 -3
- package/src/hyperframes/kit/caption-style.ts +45 -0
- package/src/hyperframes/kit/docs.ts +249 -0
- package/src/hyperframes/kit/image-layers-math.ts +115 -0
- package/src/hyperframes/kit/index.ts +74 -0
- package/src/hyperframes/kit/inter-bold-typeface.ts +3 -0
- package/src/{remotion → hyperframes}/kit/motion-math.ts +36 -2
- package/src/hyperframes/kit/music-math.ts +59 -0
- package/src/hyperframes/kit/quiet-three.ts +11 -0
- package/src/hyperframes/kit/sample-text.ts +55 -0
- package/src/hyperframes/kit/scene3d-context.ts +5 -0
- package/src/hyperframes/kit/seeded.ts +13 -0
- package/src/hyperframes/kit/sound-cues.ts +89 -0
- package/src/hyperframes/kit/sound-kinds.ts +135 -0
- package/src/{remotion → hyperframes}/kit/theme.ts +43 -39
- package/src/hyperframes/kit/three-fx-math.ts +192 -0
- package/src/hyperframes/kit/three-math.ts +145 -0
- package/src/hyperframes/kit/transition-math.ts +116 -0
- package/src/hyperframes/kit/ui-math.ts +145 -0
- package/src/hyperframes/kit/ui-theme.ts +25 -0
- package/src/hyperframes/kit/word-anchor.ts +107 -0
- package/src/hyperframes/math.ts +62 -0
- package/src/hyperframes/three.tsx +10 -0
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +44 -10
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/chromakey.ts +1 -1
- package/src/project/layers.ts +60 -0
- package/src/project/manifest.ts +59 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +4 -1
- package/src/project/serve.ts +2 -2
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/render/component-preview.ts +11 -55
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/render.ts +62 -57
- package/src/render/serve.ts +31 -0
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +15 -4
- package/src/render/validate.ts +4 -4
- package/src/render/word-check.ts +181 -0
- package/src/render/worker.ts +71 -0
- package/src/testing/conformance.ts +12 -0
- package/src/testing/fake-api.ts +4 -4
- package/src/testing/fixtures.ts +4 -1
- package/src/remotion/Root.tsx +0 -31
- package/src/remotion/kit/Music.tsx +0 -19
- package/src/remotion/kit/SceneFrame.tsx +0 -19
- package/src/remotion/kit/docs.ts +0 -124
- package/src/remotion/kit/index.ts +0 -29
- package/src/remotion/kit/music-math.ts +0 -42
- /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
- /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
- /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
- /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
- /package/src/{remotion → hyperframes}/types.ts +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The arithmetic behind the product-surface components, with no React in it. Every component's timing props are frames counted from the component's own
|
|
2
|
+
// mount: the frame useCurrentFrame() gives where the component stands (inside a SceneFrame, the scene's own frame; inside a Sequence, the sequence's).
|
|
3
|
+
// Each component has a companion function here that takes the SAME props and returns the frames of its events, in that same clock, so a sound can be
|
|
4
|
+
// placed on them: add the scene's startFrame (or pass it as `offset` to cuesFor) to make them absolute.
|
|
5
|
+
import { measureSpring } from "../frame";
|
|
6
|
+
|
|
7
|
+
export const clamp01 = (n: number) => (n > 0 ? (n < 1 ? n : 1) : 0);
|
|
8
|
+
export const outCubic = (t: number) => 1 - (1 - clamp01(t)) ** 3;
|
|
9
|
+
export const inOutCubic = (t: number) => { const x = clamp01(t); return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; };
|
|
10
|
+
|
|
11
|
+
// The frame at which each character of `text` appears when typed at `cps` characters a second from frame `start`: the first at `start`. Length is text.length.
|
|
12
|
+
export function typedFrames(text: string, cps: number, fps: number, start = 0): number[] {
|
|
13
|
+
const step = fps / Math.max(0.1, cps);
|
|
14
|
+
return Array.from({ length: [...text].length }, (_, i) => start + Math.floor(i * step));
|
|
15
|
+
}
|
|
16
|
+
// How many characters are showing at `frame`.
|
|
17
|
+
export function typedCount(frame: number, text: string, cps: number, fps: number, start = 0): number {
|
|
18
|
+
const f = typedFrames(text, cps, fps, start);
|
|
19
|
+
let n = 0;
|
|
20
|
+
while (n < f.length && f[n]! <= frame) n++;
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PromptTiming = { text: string; cps?: number; delay?: number; reply?: string; replyCps?: number; sendPause?: number; replyDelay?: number };
|
|
25
|
+
export type PromptEvents = { type: number[]; typedEnd: number; send: number; replyStart: number; reply: number[]; end: number };
|
|
26
|
+
export const PROMPT_DEFAULTS = { cps: 22, delay: 6, replyCps: 70, sendPause: 9, replyDelay: 12 } as const;
|
|
27
|
+
// The events of a PromptBox, from the same props: the frame of every typed character, the frame typing is over, the frame the send button presses, the frame
|
|
28
|
+
// the reply starts and the frame of each of its characters, and the last frame of it all.
|
|
29
|
+
export function promptFrames(p: PromptTiming, fps: number): PromptEvents {
|
|
30
|
+
const cps = p.cps ?? PROMPT_DEFAULTS.cps, delay = p.delay ?? PROMPT_DEFAULTS.delay;
|
|
31
|
+
const type = typedFrames(p.text, cps, fps, delay);
|
|
32
|
+
const typedEnd = (type[type.length - 1] ?? delay) + 1;
|
|
33
|
+
const send = typedEnd + (p.sendPause ?? PROMPT_DEFAULTS.sendPause);
|
|
34
|
+
const replyStart = send + (p.replyDelay ?? PROMPT_DEFAULTS.replyDelay);
|
|
35
|
+
const reply = p.reply ? typedFrames(p.reply, p.replyCps ?? PROMPT_DEFAULTS.replyCps, fps, replyStart) : [];
|
|
36
|
+
return { type, typedEnd, send, replyStart, reply, end: Math.max(send + 8, (reply[reply.length - 1] ?? 0) + 1) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LineKind = "cmd" | "out" | "ok" | "warn" | "edit";
|
|
40
|
+
export type TerminalLine = { kind: LineKind; text: string; add?: number; del?: number; wait?: number };
|
|
41
|
+
// The frame each line of a TerminalLog appears: `rate` lines a second (default 3) from `delay` (default 6); a line's `wait` adds frames before it.
|
|
42
|
+
export function lineFrames(lines: readonly TerminalLine[], rate = 3, fps = 30, delay = 6): number[] {
|
|
43
|
+
const step = fps / Math.max(0.1, rate);
|
|
44
|
+
let t = delay;
|
|
45
|
+
return lines.map((l, i) => { t += (i === 0 ? 0 : step) + (l.wait ?? 0); return Math.round(t); });
|
|
46
|
+
}
|
|
47
|
+
export type TerminalTiming = { lines: readonly TerminalLine[]; rate?: number; delay?: number; checklist?: readonly string[]; checkEvery?: number };
|
|
48
|
+
// The events of a TerminalLog: each line's frame, each checklist item's frame (the strike-through, one every `checkEvery` frames, default 16, starting a beat
|
|
49
|
+
// after the last line), and the frame the work is done.
|
|
50
|
+
export function terminalFrames(p: TerminalTiming, fps: number): { lines: number[]; checks: number[]; done: number } {
|
|
51
|
+
const lines = lineFrames(p.lines, p.rate, fps, p.delay);
|
|
52
|
+
const first = (lines[lines.length - 1] ?? p.delay ?? 6) + 6, every = p.checkEvery ?? 16;
|
|
53
|
+
const checks = (p.checklist ?? []).map((_, i) => first + i * every);
|
|
54
|
+
return { lines, checks, done: Math.max(lines[lines.length - 1] ?? 0, checks[checks.length - 1] ?? 0) + 10 };
|
|
55
|
+
}
|
|
56
|
+
// "m:ss" for `seconds`.
|
|
57
|
+
export const clock = (seconds: number): string => `${Math.floor(Math.max(0, seconds) / 60)}:${String(Math.floor(Math.max(0, seconds) % 60)).padStart(2, "0")}`;
|
|
58
|
+
|
|
59
|
+
export type CursorPoint = { x: number; y: number; click?: boolean; hold?: number };
|
|
60
|
+
export const CURSOR_MOVE_FRAMES = 22;
|
|
61
|
+
// The frame the cursor reaches each point: the first at `delay` (default 4), then `moveFrames` (default 22) after leaving the one before, which it does
|
|
62
|
+
// after `hold` frames (default 10 on a click, 5 otherwise).
|
|
63
|
+
export function cursorArrivals(points: readonly CursorPoint[], o: { delay?: number; moveFrames?: number } = {}): number[] {
|
|
64
|
+
const move = o.moveFrames ?? CURSOR_MOVE_FRAMES;
|
|
65
|
+
let t = o.delay ?? 4;
|
|
66
|
+
return points.map((p, i) => { if (i > 0) t += (points[i - 1]!.hold ?? (points[i - 1]!.click ? 10 : 5)) + move; return t; });
|
|
67
|
+
}
|
|
68
|
+
// The frame of each click: 3 frames after the cursor reaches a point marked `click`.
|
|
69
|
+
export function clickFrames(points: readonly CursorPoint[], o: { delay?: number; moveFrames?: number } = {}): number[] {
|
|
70
|
+
const at = cursorArrivals(points, o);
|
|
71
|
+
return points.flatMap((p, i) => (p.click ? [at[i]! + 3] : []));
|
|
72
|
+
}
|
|
73
|
+
// Where the cursor is at `frame`, as fractions of the frame: it travels each leg on an ease with a gentle arc, never a straight line, and rests on the points.
|
|
74
|
+
export function cursorAt(points: readonly CursorPoint[], frame: number, o: { delay?: number; moveFrames?: number } = {}): { x: number; y: number } {
|
|
75
|
+
const at = cursorArrivals(points, o), move = o.moveFrames ?? CURSOR_MOVE_FRAMES;
|
|
76
|
+
if (points.length === 0) return { x: 0.5, y: 0.5 };
|
|
77
|
+
for (let i = points.length - 1; i > 0; i--) {
|
|
78
|
+
if (frame < at[i]! - move) continue;
|
|
79
|
+
const a = points[i - 1]!, b = points[i]!, t = inOutCubic((frame - (at[i]! - move)) / move);
|
|
80
|
+
const dx = b.x - a.x, dy = b.y - a.y, bend = 0.14 * (i % 2 ? 1 : -1);
|
|
81
|
+
const cx = (a.x + b.x) / 2 - dy * bend, cy = (a.y + b.y) / 2 + dx * bend;
|
|
82
|
+
return { x: (1 - t) ** 2 * a.x + 2 * (1 - t) * t * cx + t * t * b.x, y: (1 - t) ** 2 * a.y + 2 * (1 - t) * t * cy + t * t * b.y };
|
|
83
|
+
}
|
|
84
|
+
return { x: points[0]!.x, y: points[0]!.y };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The frame at which a spring of the given kind has settled (within 1% of its end): the frame a panel or a badge is seen to land.
|
|
88
|
+
export const settledAfter = (config: { stiffness: number; damping: number }, fps: number): number => measureSpring({ fps, config, threshold: 0.01 });
|
|
89
|
+
export const PANEL_SPRING = { stiffness: 170, damping: 19 };
|
|
90
|
+
// The frame a GlassPanel has settled, from its `delay` (default 0).
|
|
91
|
+
export const panelSettleFrame = (delay = 0, fps = 30): number => delay + settledAfter(PANEL_SPRING, fps);
|
|
92
|
+
|
|
93
|
+
export type ChapterTiming = { delay?: number; badges?: number };
|
|
94
|
+
// The events of a ChapterFrame: the frame each badge pops (the first 14 frames in, then 6 apart) and the frame the progress rule has filled.
|
|
95
|
+
export function chapterFrames(p: ChapterTiming = {}): { badges: number[]; progressDone: number } {
|
|
96
|
+
const d = p.delay ?? 0;
|
|
97
|
+
return { badges: Array.from({ length: Math.min(3, p.badges ?? 0) }, (_, i) => d + 14 + i * 6), progressDone: d + 24 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const headlineWords = (text: string): string[] => text.trim().split(/\s+/).filter(Boolean);
|
|
101
|
+
const bare = (w: string) => w.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase();
|
|
102
|
+
// The index of `keyword` among the words of `text`, ignoring case and punctuation; throws, listing the words, when it is not one of them.
|
|
103
|
+
export function keywordIndex(text: string, keyword: string): number {
|
|
104
|
+
const words = headlineWords(text), i = words.findIndex((w) => bare(w) === bare(keyword));
|
|
105
|
+
if (i < 0) throw new Error(`Headline: the keyword "${keyword}" is not one of the words of "${text}". The words are: ${words.join(", ")}.`);
|
|
106
|
+
return i;
|
|
107
|
+
}
|
|
108
|
+
// The events of a Headline: the frame each word lands (`per` frames apart, 4 unless set, from `delay`) and the frame its emphasis finishes.
|
|
109
|
+
export function headlineFrames(text: string, o: { delay?: number; per?: number } = {}): { words: number[]; emphasisDone: number } {
|
|
110
|
+
const n = headlineWords(text).length, d = o.delay ?? 0, per = o.per ?? 4;
|
|
111
|
+
const words = Array.from({ length: n }, (_, i) => d + i * per + 6);
|
|
112
|
+
return { words, emphasisDone: (words[n - 1] ?? d) + 14 };
|
|
113
|
+
}
|
|
114
|
+
// Splits the words into the fewest lines that keep the longest near `maxChars`, in order, and returns the lines' word indices.
|
|
115
|
+
export function headlineLines(words: readonly string[], maxChars = 12): number[][] {
|
|
116
|
+
const lines: number[][] = [[]];
|
|
117
|
+
let len = 0;
|
|
118
|
+
words.forEach((w, i) => {
|
|
119
|
+
const cur = lines[lines.length - 1]!;
|
|
120
|
+
if (cur.length && len + 1 + w.length > maxChars) { lines.push([i]); len = w.length; } else { cur.push(i); len += (cur.length > 1 ? 1 : 0) + w.length; }
|
|
121
|
+
});
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The value of a CounterRoll at `frame`: it eases out over `frames` frames from `delay` and holds `to`.
|
|
126
|
+
export function rollValue(frame: number, o: { from?: number; to: number; frames?: number; delay?: number }): number {
|
|
127
|
+
const from = o.from ?? 0, t = (frame - (o.delay ?? 0)) / Math.max(1, o.frames ?? 45);
|
|
128
|
+
return from + (o.to - from) * (1 - (1 - clamp01(t)) ** 4);
|
|
129
|
+
}
|
|
130
|
+
// The frame a CounterRoll lands on its number (`delay` + `frames`, defaults 0 and 45): put the settle chime here.
|
|
131
|
+
export const settleFrame = (o: { delay?: number; frames?: number } = {}): number => (o.delay ?? 0) + (o.frames ?? 45);
|
|
132
|
+
// The frame of the last tick of the roll: it starts at `delay`.
|
|
133
|
+
export const rollStart = (o: { delay?: number } = {}): number => o.delay ?? 0;
|
|
134
|
+
|
|
135
|
+
// A timecode "mm:ss:ff" of `frame`.
|
|
136
|
+
export const timecode = (frame: number, fps: number): string => {
|
|
137
|
+
const s = Math.floor(frame / fps);
|
|
138
|
+
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}:${String(Math.floor(frame % fps)).padStart(2, "0")}`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// The frames of a BrowserFrame: the frame it has settled, and the frames the sheen starts and ends.
|
|
142
|
+
export function browserFrames(o: { delay?: number; fps?: number } = {}): { settled: number; sheenStart: number; sheenEnd: number } {
|
|
143
|
+
const d = o.delay ?? 0;
|
|
144
|
+
return { settled: d + settledAfter(PANEL_SPRING, o.fps ?? 30), sheenStart: d + 18, sheenEnd: d + 44 };
|
|
145
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { inkOn, luminance, mixHex } from "./three-math";
|
|
2
|
+
|
|
3
|
+
// What the product-surface components (PromptBox, TerminalLog, GlassPanel, ...) share: the three looks, the colours each one gives, and the helpers that
|
|
4
|
+
// say where a component sits. Nothing here needs React.
|
|
5
|
+
export type UiTheme = "light" | "dark" | "glass";
|
|
6
|
+
export const DEFAULT_HERO = "#7C3AED";
|
|
7
|
+
|
|
8
|
+
// "light" on a light ground and "dark" on a dark one: the theme a component should take when its ground is `bg`.
|
|
9
|
+
export const themeFor = (bg: string): UiTheme => (luminance(bg) > 0.5 ? "light" : "dark");
|
|
10
|
+
|
|
11
|
+
export type UiColors = { panel: string; stroke: string; ink: string; dim: string; field: string; chip: string; shadow: string; heroInk: string; ok: string; warn: string; bad: string };
|
|
12
|
+
|
|
13
|
+
// One set of colours per theme. `glass` is the dark slab with a lit hairline: it is meant to sit over a coloured glow and is a little see-through.
|
|
14
|
+
export function uiColors(theme: UiTheme, hero: string): UiColors {
|
|
15
|
+
const heroInk = inkOn(hero);
|
|
16
|
+
if (theme === "light") return { panel: "#ffffff", stroke: "rgba(20,20,30,0.12)", ink: "#16161d", dim: "#6b6b78", field: "#f1f1f5", chip: "#e9e9f0", shadow: "rgba(20,20,40,0.22)", heroInk, ok: "#16a34a", warn: "#d97706", bad: "#dc2626" };
|
|
17
|
+
if (theme === "glass") return { panel: "rgba(14,14,22,0.62)", stroke: "rgba(255,255,255,0.20)", ink: "#f4f4f5", dim: "#a8a8b6", field: "rgba(255,255,255,0.07)", chip: "rgba(255,255,255,0.12)", shadow: "rgba(0,0,0,0.5)", heroInk, ok: "#4ade80", warn: "#fbbf24", bad: "#f87171" };
|
|
18
|
+
return { panel: "#121219", stroke: "rgba(255,255,255,0.10)", ink: "#f4f4f5", dim: "#9a9aa6", field: "#1b1b26", chip: "#262634", shadow: "rgba(0,0,0,0.55)", heroInk, ok: "#4ade80", warn: "#fbbf24", bad: "#f87171" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const rgba = (hex: string, a: number): string => {
|
|
22
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(mixHex(hex, hex, 0));
|
|
23
|
+
const n = parseInt(m ? m[1]! : "000000", 16);
|
|
24
|
+
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
|
25
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Word anchors: where in the film a spoken word falls, so that what illustrates a word can be placed on it.
|
|
2
|
+
// Everything here is a pure function over a manifest scene (`manifest.scenes[i]`, with its `words`), so it is the same in the studio, in a test and in a render.
|
|
3
|
+
// Frames are counted from the start of the scene (what its own useCurrentFrame() gives) unless `absolute: true` adds the scene's startFrame.
|
|
4
|
+
import type { WordTiming } from "../../pipeline/schema";
|
|
5
|
+
|
|
6
|
+
// The manifest's frame rate is always this one; pass `fps` only for a scene that came from somewhere else.
|
|
7
|
+
export const WORD_FPS = 30;
|
|
8
|
+
// An entrance needs a few frames to become visible, so it starts this many frames before its word.
|
|
9
|
+
export const DEFAULT_LEAD = 3;
|
|
10
|
+
// A beat counts as "at the word" when it is no more than this many frames from the word's start.
|
|
11
|
+
export const DEFAULT_BEAT_WINDOW = 3;
|
|
12
|
+
// The thing a word names is never seen more than this many frames before the word starts.
|
|
13
|
+
export const MAX_EARLY_FRAMES = 3;
|
|
14
|
+
|
|
15
|
+
export type WordScene = { id?: string; startFrame: number; durationFrames?: number; words: WordTiming[] };
|
|
16
|
+
|
|
17
|
+
export type WordFrameOptions = {
|
|
18
|
+
// The 1-based occurrence of the word when the scene says it more than once (default 1).
|
|
19
|
+
nth?: number;
|
|
20
|
+
// "start" (default): the frame the word starts. "end": the frame it ends (for a phrase, the end of its last word).
|
|
21
|
+
edge?: "start" | "end";
|
|
22
|
+
// Add the scene's startFrame, giving a frame of the whole film.
|
|
23
|
+
absolute?: boolean;
|
|
24
|
+
fps?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// A word as it is compared: lower case, with everything but letters and digits removed ("Meetings," is "meetings", "don't" is "dont").
|
|
28
|
+
export const normalizeWord = (w: string): string => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
29
|
+
|
|
30
|
+
const spoken = (scene: WordScene): { norm: string; word: WordTiming }[] =>
|
|
31
|
+
scene.words.map((word) => ({ norm: normalizeWord(word.word), word })).filter((w) => w.norm !== "");
|
|
32
|
+
|
|
33
|
+
// The words that make up `phrase`, matched against the scene's words, with where the phrase's `nth` occurrence is.
|
|
34
|
+
function findPhrase(scene: WordScene, phrase: string, nth: number): { first: WordTiming; last: WordTiming } {
|
|
35
|
+
const parts = phrase.split(/\s+/).map(normalizeWord).filter(Boolean);
|
|
36
|
+
const list = spoken(scene);
|
|
37
|
+
const where = scene.id ? `scene "${scene.id}"` : "the scene";
|
|
38
|
+
if (!parts.length) throw new Error(`wordFrame: the word is empty. ${where} says: ${list.map((w) => w.word.word).join(" ")}`);
|
|
39
|
+
if (!Number.isInteger(nth) || nth < 1) throw new Error(`wordFrame: nth must be 1 or more, got ${nth}.`);
|
|
40
|
+
let seen = 0;
|
|
41
|
+
for (let i = 0; i + parts.length <= list.length; i++) {
|
|
42
|
+
if (parts.every((p, k) => list[i + k]!.norm === p) && ++seen === nth) return { first: list[i]!.word, last: list[i + parts.length - 1]!.word };
|
|
43
|
+
}
|
|
44
|
+
const had = seen ? ` (it is said ${seen} time${seen === 1 ? "" : "s"}, not ${nth})` : "";
|
|
45
|
+
throw new Error(`wordFrame: "${phrase}"${nth > 1 ? ` (occurrence ${nth})` : ""} is not in ${where}${had}. Its words are: ${list.map((w) => w.word.word).join(" ")}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The frame, inside the scene, at which `word` starts (or ends, with edge: "end"). `word` ignores case and punctuation; a phrase of several words
|
|
49
|
+
// ("whole week") matches consecutive words and gives the first one's start. A word the scene does not say throws an Error that lists the scene's
|
|
50
|
+
// words, so a misspelt word stops the render instead of silently timing to frame 0.
|
|
51
|
+
export function wordFrame(scene: WordScene, word: string, opts: WordFrameOptions = {}): number {
|
|
52
|
+
const { first, last } = findPhrase(scene, word, opts.nth ?? 1);
|
|
53
|
+
const fps = opts.fps ?? WORD_FPS;
|
|
54
|
+
const sec = opts.edge === "end" ? last.endSec : first.startSec;
|
|
55
|
+
let frame = Math.max(0, Math.round(sec * fps));
|
|
56
|
+
if (scene.durationFrames !== undefined) frame = Math.min(frame, Math.max(0, scene.durationFrames - 1));
|
|
57
|
+
return frame + (opts.absolute ? scene.startFrame : 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Several words at once, in the order given.
|
|
61
|
+
export const wordFrames = (scene: WordScene, words: string[], opts: WordFrameOptions = {}): number[] => words.map((w) => wordFrame(scene, w, opts));
|
|
62
|
+
|
|
63
|
+
// The scene with this id. Throws an Error that lists the ids when there is none.
|
|
64
|
+
export function sceneById<S extends { id: string }>(manifest: { scenes: S[] }, id: string): S {
|
|
65
|
+
const found = manifest.scenes.find((s) => s.id === id);
|
|
66
|
+
if (!found) throw new Error(`sceneById: there is no scene "${id}". The scenes are: ${manifest.scenes.map((s) => s.id).join(", ")}`);
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type OnWordOptions = WordFrameOptions & {
|
|
71
|
+
// How many frames before the word the entrance starts (default 3).
|
|
72
|
+
lead?: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// The frame at which to START an entrance (a `delay`, a `from`, an `at`) so that it is seen landing on its word: the word's frame minus `lead`
|
|
76
|
+
// (default 3), never below 0.
|
|
77
|
+
export function onWord(scene: WordScene, word: string, opts: OnWordOptions = {}): number {
|
|
78
|
+
const lead = opts.lead ?? DEFAULT_LEAD;
|
|
79
|
+
const at = wordFrame(scene, word, { ...opts, absolute: false });
|
|
80
|
+
return Math.max(0, at - lead) + (opts.absolute ? scene.startFrame : 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type OnWordBeatOptions = OnWordOptions & {
|
|
84
|
+
// A beat counts only when it is at most this many frames from the word's start (default 3).
|
|
85
|
+
window?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Beat and word together. The voice is the clock for what illustrates a word, so the word wins; the beat is used only when one lies within `window`
|
|
89
|
+
// frames of the word (default 3), and then only to let the entrance land on it. The result is never earlier than onWord's, so what a word names
|
|
90
|
+
// is never seen more than 3 frames before the word starts. `music` is manifest.music (its beatFrames are frames of the whole film); without a
|
|
91
|
+
// track it is onWord.
|
|
92
|
+
export function onWordBeat(scene: WordScene, word: string, music: { beatFrames: number[] } | undefined, opts: OnWordBeatOptions = {}): number {
|
|
93
|
+
const lead = opts.lead ?? DEFAULT_LEAD;
|
|
94
|
+
const window = opts.window ?? DEFAULT_BEAT_WINDOW;
|
|
95
|
+
const at = wordFrame(scene, word, { ...opts, absolute: false });
|
|
96
|
+
const wordAbs = scene.startFrame + at;
|
|
97
|
+
const base = Math.max(0, at - lead);
|
|
98
|
+
let start = base;
|
|
99
|
+
let best: number | undefined;
|
|
100
|
+
for (const b of music?.beatFrames ?? []) {
|
|
101
|
+
// A beat before the word is never used: it would show the thing early.
|
|
102
|
+
if (b < wordAbs || b - wordAbs > window) continue;
|
|
103
|
+
if (best === undefined || b - wordAbs < best - wordAbs) best = b;
|
|
104
|
+
}
|
|
105
|
+
if (best !== undefined) start = Math.max(base, best - scene.startFrame - lead);
|
|
106
|
+
return start + (opts.absolute ? scene.startFrame : 0);
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Seekable animation math. These functions have no browser or React dependency.
|
|
2
|
+
export type SpringConfig = { damping?: number; stiffness?: number; mass?: number; overshootClamping?: boolean };
|
|
3
|
+
export function spring({ frame, fps, config = {}, from = 0, to = 1, delay = 0, durationInFrames, durationRestThreshold = 0.005, reverse = false }: { frame: number; fps: number; config?: SpringConfig; from?: number; to?: number; delay?: number; durationInFrames?: number; durationRestThreshold?: number; reverse?: boolean }): number {
|
|
4
|
+
const length = durationInFrames ?? measureSpring({ fps, config, threshold: durationRestThreshold });
|
|
5
|
+
let f = frame - delay;
|
|
6
|
+
if (reverse) f = length - f;
|
|
7
|
+
if (f <= 0) return from;
|
|
8
|
+
if (durationInFrames && f >= durationInFrames) return to;
|
|
9
|
+
const t = f / fps * (durationInFrames ? measureSpring({ fps, config, threshold: durationRestThreshold }) / durationInFrames : 1);
|
|
10
|
+
const m = config.mass ?? 1, k = config.stiffness ?? 100, c = config.damping ?? 10;
|
|
11
|
+
const w = Math.sqrt(k / m), z = c / (2 * Math.sqrt(k * m));
|
|
12
|
+
let p: number;
|
|
13
|
+
if (z < 1) { const wd = w * Math.sqrt(1 - z * z); p = 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + z * w / wd * Math.sin(wd * t)); }
|
|
14
|
+
else if (z === 1) p = 1 - (1 + w * t) * Math.exp(-w * t);
|
|
15
|
+
else { const r = Math.sqrt(z * z - 1), a = -w * (z - r), b = -w * (z + r); p = 1 - (b * Math.exp(a * t) - a * Math.exp(b * t)) / (b - a); }
|
|
16
|
+
return from + (to - from) * (config.overshootClamping ? Math.min(1, p) : p);
|
|
17
|
+
}
|
|
18
|
+
export function measureSpring({ fps, config = {}, threshold = 0.005 }: { fps: number; config?: SpringConfig; threshold?: number }): number {
|
|
19
|
+
const m = config.mass ?? 1, k = config.stiffness ?? 100, c = config.damping ?? 10;
|
|
20
|
+
const w = Math.sqrt(k / m), z = c / (2 * Math.sqrt(k * m));
|
|
21
|
+
// Require a full second inside the rest band so an oscillation's zero crossing cannot end the spring.
|
|
22
|
+
let last = 0;
|
|
23
|
+
for (let f = 0; f < fps * 60; f++) {
|
|
24
|
+
const t = f / fps;
|
|
25
|
+
let error: number;
|
|
26
|
+
if (z < 1) { const wd = w * Math.sqrt(1 - z * z); error = Math.exp(-z * w * t) * (Math.cos(wd * t) + z * w / wd * Math.sin(wd * t)); }
|
|
27
|
+
else if (z === 1) error = (1 + w * t) * Math.exp(-w * t);
|
|
28
|
+
else { const r = Math.sqrt(z * z - 1), a = -w * (z - r), b = -w * (z + r); error = (b * Math.exp(a * t) - a * Math.exp(b * t)) / (b - a); }
|
|
29
|
+
if (Math.abs(error) > threshold) last = f;
|
|
30
|
+
if (f - last >= fps) return last + 1;
|
|
31
|
+
}
|
|
32
|
+
throw new Error("Spring did not settle within 60 seconds.");
|
|
33
|
+
}
|
|
34
|
+
type Extrapolate = "extend" | "clamp" | "identity" | "wrap";
|
|
35
|
+
export function interpolate(value: number, input: readonly number[], output: readonly number[], options: { easing?: (t: number) => number; extrapolateLeft?: Extrapolate; extrapolateRight?: Extrapolate } = {}): number {
|
|
36
|
+
if (input.length !== output.length || input.length < 2 || input.some((n, i) => i > 0 && n <= input[i - 1]!)) throw new Error("Interpolation ranges must have equal length and increasing input values.");
|
|
37
|
+
const lo = input[0]!, hi = input[input.length - 1]!;
|
|
38
|
+
if (value < lo || value > hi) { const mode = value < lo ? options.extrapolateLeft : options.extrapolateRight; if (mode === "identity") return value; if (mode === "clamp") value = Math.min(hi, Math.max(lo, value)); if (mode === "wrap") value = lo + ((value - lo) % (hi - lo) + hi - lo) % (hi - lo); }
|
|
39
|
+
let i = 0; while (i < input.length - 2 && value > input[i + 1]!) i++;
|
|
40
|
+
const t = (value - input[i]!) / (input[i + 1]! - input[i]!);
|
|
41
|
+
return output[i]! + (output[i + 1]! - output[i]!) * (options.easing ?? Easing.linear)(t);
|
|
42
|
+
}
|
|
43
|
+
export const Easing = {
|
|
44
|
+
linear: (t: number) => t, quad: (t: number) => t * t, cubic: (t: number) => t ** 3,
|
|
45
|
+
sin: (t: number) => 1 - Math.cos(t * Math.PI / 2), exp: (t: number) => t === 0 ? 0 : 2 ** (10 * (t - 1)),
|
|
46
|
+
in: (fn: (t: number) => number) => fn,
|
|
47
|
+
out: (fn: (t: number) => number) => (t: number) => 1 - fn(1 - t),
|
|
48
|
+
inOut: (fn: (t: number) => number) => (t: number) => t < 0.5 ? fn(t * 2) / 2 : 1 - fn((1 - t) * 2) / 2,
|
|
49
|
+
bezier: (x1: number, y1: number, x2: number, y2: number) => (t: number) => {
|
|
50
|
+
if (t <= 0 || t >= 1) return t;
|
|
51
|
+
const curve = (u: number, a: number, b: number) => 3 * (1 - u) ** 2 * u * a + 3 * (1 - u) * u * u * b + u ** 3;
|
|
52
|
+
let lo = 0, hi = 1;
|
|
53
|
+
for (let i = 0; i < 40; i++) { const u = (lo + hi) / 2; if (curve(u, x1, x2) < t) lo = u; else hi = u; }
|
|
54
|
+
return curve((lo + hi) / 2, y1, y2);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export function random(seed: string | number): number {
|
|
58
|
+
let h = 2166136261;
|
|
59
|
+
for (const c of String(seed)) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
|
|
60
|
+
h ^= h >>> 16; h = Math.imul(h, 0x7feb352d); h ^= h >>> 15; h = Math.imul(h, 0x846ca68b);
|
|
61
|
+
return ((h ^ h >>> 16) >>> 0) / 4294967296;
|
|
62
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Canvas, useThree, type CanvasProps } from "@react-three/fiber";
|
|
3
|
+
import { FrameContext, useCurrentFrame, delayRender, continueRender } from "./frame";
|
|
4
|
+
const Draw: React.FC = () => { const f = useCurrentFrame(), s = useThree(); React.useLayoutEffect(() => { s.invalidate(); }, [f, s]); return null; };
|
|
5
|
+
export const ThreeCanvas: React.FC<CanvasProps & { width: number; height: number }> = ({ width, height, children, style, ...props }) => {
|
|
6
|
+
const state = React.useContext(FrameContext);
|
|
7
|
+
const [handle] = React.useState(() => delayRender("3D canvas"));
|
|
8
|
+
React.useEffect(() => () => continueRender(handle), [handle]);
|
|
9
|
+
return <Canvas {...props} onCreated={() => continueRender(handle)} frameloop="demand" gl={{ preserveDrawingBuffer: true, alpha: true }} style={{ width, height, ...style }}><FrameContext.Provider value={state}>{children}<Draw /></FrameContext.Provider></Canvas>;
|
|
10
|
+
};
|
package/src/pipeline/beatsnap.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type SnapInput = {
|
|
|
12
12
|
// Beats as frame numbers, ascending. They should reach past the end of the video.
|
|
13
13
|
beatFrames: number[];
|
|
14
14
|
fps: number;
|
|
15
|
+
// "next" holds a scene to the next whole beat (narration cannot be cut). "nearest" moves each end to the closest point on a finer grid
|
|
16
|
+
// (a film with no voice can be shortened as well as lengthened, so its total stays near the plan).
|
|
17
|
+
grid?: "next" | "nearest";
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
const firstAtOrAfter = (beats: number[], frame: number): number | undefined => beats.find((b) => b >= frame);
|
|
@@ -21,6 +24,7 @@ const firstAtOrAfter = (beats: number[], frame: number): number | undefined => b
|
|
|
21
24
|
export function snapToBeats(input: SnapInput): number[] {
|
|
22
25
|
const { naturalFrames, beatFrames, fps } = input;
|
|
23
26
|
if (beatFrames.length < 2) return [...naturalFrames];
|
|
27
|
+
if (input.grid === "nearest") return snapToNearest(naturalFrames, beatFrames);
|
|
24
28
|
const gaps = beatFrames.slice(1).map((b, i) => b - beatFrames[i]!).sort((a, b) => a - b);
|
|
25
29
|
const period = Math.max(1, gaps[Math.floor(gaps.length / 2)]!);
|
|
26
30
|
let cursor = 0;
|
|
@@ -40,6 +44,74 @@ export function snapToBeats(input: SnapInput): number[] {
|
|
|
40
44
|
});
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
// How far a scene change of a narrated film may move to reach a beat, in frames, either way.
|
|
48
|
+
export const NARRATED_SNAP_FRAMES = 4;
|
|
49
|
+
// A boundary never comes closer than this to the last word of the scene it ends, in frames.
|
|
50
|
+
export const NARRATED_WORD_CLEARANCE = 2;
|
|
51
|
+
|
|
52
|
+
// Scene changes for a narrated film. The voice sets where a scene ends; a boundary is moved to the nearest beat only when that is at most
|
|
53
|
+
// `maxMove` frames away either way and leaves at least `clearance` frames after the scene's last word (a boundary never cuts into a word). The
|
|
54
|
+
// next scene's audio starts on its first frame, so it moves with the boundary. Every other boundary stays where the narration puts it, and the
|
|
55
|
+
// last scene is not touched: the film's end is not a scene change. Each boundary is judged from where its scene actually starts, so
|
|
56
|
+
// nothing adds up. `lastWordEndFrames[i]` is when scene i's last word ends, in frames from its own start.
|
|
57
|
+
export function snapNarrated(input: { naturalFrames: number[]; lastWordEndFrames: number[]; beatFrames: number[]; maxMove?: number; clearance?: number }): { frames: number[]; onBeat: boolean[] } {
|
|
58
|
+
const { naturalFrames, lastWordEndFrames, beatFrames } = input;
|
|
59
|
+
const maxMove = input.maxMove ?? NARRATED_SNAP_FRAMES, clearance = input.clearance ?? NARRATED_WORD_CLEARANCE;
|
|
60
|
+
let cursor = 0;
|
|
61
|
+
const onBeat: boolean[] = [];
|
|
62
|
+
const frames = naturalFrames.map((natural, i) => {
|
|
63
|
+
let end = cursor + natural;
|
|
64
|
+
if (i < naturalFrames.length - 1) {
|
|
65
|
+
const floor = cursor + (lastWordEndFrames[i] ?? 0) + clearance;
|
|
66
|
+
let best: number | undefined;
|
|
67
|
+
for (const b of beatFrames) {
|
|
68
|
+
if (Math.abs(b - end) > maxMove || b < floor) continue;
|
|
69
|
+
if (best === undefined || Math.abs(b - end) < Math.abs(best - end) || (Math.abs(b - end) === Math.abs(best - end) && b > best)) best = b;
|
|
70
|
+
}
|
|
71
|
+
if (best !== undefined) end = best;
|
|
72
|
+
onBeat.push(beatFrames.includes(end));
|
|
73
|
+
}
|
|
74
|
+
const length = end - cursor;
|
|
75
|
+
cursor = end;
|
|
76
|
+
return length;
|
|
77
|
+
});
|
|
78
|
+
return { frames, onBeat };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The beat frames with `divisions - 1` evenly spaced points between each pair, rounded to frames. The last beat is followed by one more
|
|
82
|
+
// at the median spacing, so a point just after it exists too.
|
|
83
|
+
export function gridPoints(beatFrames: number[], divisions: number): number[] {
|
|
84
|
+
if (beatFrames.length < 2) return [...beatFrames];
|
|
85
|
+
const gaps = beatFrames.slice(1).map((b, i) => b - beatFrames[i]!).sort((a, b) => a - b);
|
|
86
|
+
const period = Math.max(1, gaps[Math.floor(gaps.length / 2)]!);
|
|
87
|
+
const all = [...beatFrames, beatFrames[beatFrames.length - 1]! + period];
|
|
88
|
+
const out = new Set<number>();
|
|
89
|
+
for (let i = 0; i + 1 < all.length; i++) for (let k = 0; k < divisions; k++) out.add(Math.round(all[i]! + ((all[i + 1]! - all[i]!) * k) / divisions));
|
|
90
|
+
out.add(all[all.length - 1]!);
|
|
91
|
+
return [...out].sort((a, b) => a - b);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A scene of at least 1.5 beats ends on the nearest whole beat; a shorter one on the nearest half beat, and under half a beat the nearest
|
|
95
|
+
// quarter. The target is the planned end (the sum of the natural lengths), so rounding errors do not add up; a scene is never made empty.
|
|
96
|
+
function snapToNearest(naturalFrames: number[], beatFrames: number[]): number[] {
|
|
97
|
+
const gaps = beatFrames.slice(1).map((b, i) => b - beatFrames[i]!).sort((a, b) => a - b);
|
|
98
|
+
const period = Math.max(1, gaps[Math.floor(gaps.length / 2)]!);
|
|
99
|
+
const grids = new Map<number, number[]>();
|
|
100
|
+
const grid = (d: number) => grids.get(d) ?? (grids.set(d, gridPoints(beatFrames, d)), grids.get(d)!);
|
|
101
|
+
let cursor = 0, planned = 0;
|
|
102
|
+
return naturalFrames.map((natural) => {
|
|
103
|
+
planned += natural;
|
|
104
|
+
const beats = natural / period;
|
|
105
|
+
const points = grid(beats >= 1.5 ? 1 : beats >= 0.5 ? 2 : 4).filter((f) => f > cursor);
|
|
106
|
+
let target = planned;
|
|
107
|
+
if (points.length) target = points.reduce((best, f) => (Math.abs(f - planned) < Math.abs(best - planned) ? f : best));
|
|
108
|
+
else target = Math.max(planned, cursor + 1);
|
|
109
|
+
const frames = target - cursor;
|
|
110
|
+
cursor = target;
|
|
111
|
+
return frames;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
43
115
|
// Beats in seconds, continued at the tempo's own spacing until `untilSec`, for a video longer than the track (the track loops).
|
|
44
116
|
export function extendBeats(beats: number[], bpm: number, untilSec: number): number[] {
|
|
45
117
|
if (!beats.length) return [];
|
package/src/pipeline/review.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { gapSec, isVoiceless, LAST_TAIL_SEC, PACE_SPEED, VOICE_WORDS_PER_SEC, type AssetRecord, type ScenePlan } from "./schema";
|
|
2
2
|
|
|
3
3
|
const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;
|
|
4
4
|
|
|
5
|
-
//
|
|
5
|
+
// What the voice speaks at, in words a second, for this plan's pace.
|
|
6
|
+
const speechRate = (plan: ScenePlan) => VOICE_WORDS_PER_SEC * PACE_SPEED[plan.pace ?? "normal"];
|
|
7
|
+
|
|
8
|
+
// The estimate the length advice below is based on: each scene's words at the voice's real speed (2.25 words a second at the normal pace, the
|
|
9
|
+
// pauses within a sentence included), the plan's silence between sentences after every scene but the last, and the last scene's tail after its
|
|
10
|
+
// last word. It matches the manifest `reelkit assets voiceover` builds. A video with no voice has no words to count: its length is the sum of the
|
|
11
|
+
// scenes' own seconds.
|
|
6
12
|
export function estimateLength(plan: ScenePlan): { words: number; seconds: number } {
|
|
13
|
+
if (isVoiceless(plan)) return { words: 0, seconds: plan.scenes.reduce((n, s) => n + (s.seconds ?? 0), 0) };
|
|
7
14
|
const total = plan.scenes.reduce((n, s) => n + words(s.narration), 0);
|
|
8
|
-
return { words: total, seconds: total /
|
|
15
|
+
return { words: total, seconds: total / speechRate(plan) + (plan.scenes.length - 1) * gapSec(plan) + LAST_TAIL_SEC };
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
// Each scene's estimated length in seconds,
|
|
18
|
+
// Each scene's estimated length in seconds: its speech and what follows it (the gap, or the tail for the last scene), so they add up to the whole estimate.
|
|
12
19
|
export function sceneSeconds(plan: ScenePlan): { id: string; seconds: number }[] {
|
|
13
|
-
return plan.scenes.map((s) => ({ id: s.id, seconds: words(s.narration) /
|
|
20
|
+
return plan.scenes.map((s, i) => ({ id: s.id, seconds: isVoiceless(plan) ? (s.seconds ?? 0) : words(s.narration) / speechRate(plan) + (i === plan.scenes.length - 1 ? LAST_TAIL_SEC : gapSec(plan)) }));
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
// How uneven the scene lengths are: the standard deviation over the mean (0 when they are all the same).
|
|
@@ -29,12 +36,14 @@ export function rhythmNote(plan: ScenePlan): string | undefined {
|
|
|
29
36
|
const cv = lengthVariation(values);
|
|
30
37
|
if (cv >= 0.25 && values.some((v) => v < mean / 2)) return undefined;
|
|
31
38
|
const shortest = lengths.reduce((a, b) => (b.seconds < a.seconds ? b : a)), longest = lengths.reduce((a, b) => (b.seconds > a.seconds ? b : a));
|
|
32
|
-
|
|
39
|
+
const fix = isVoiceless(plan) ? "Make one or two scenes much shorter (under a second) and let one run long." : "Make one or two scenes much shorter (a hit of a few words) and let one run long.";
|
|
40
|
+
return `The scenes are too even in length (the shortest, ${shortest.id}, is about ${shortest.seconds.toFixed(1)}s and the longest, ${longest.id}, about ${longest.seconds.toFixed(1)}s). ${fix}`;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
// Soft quality checks on a plan that already passes the hard rules in validatePlan.
|
|
36
44
|
// These are things worth improving in the script; they never fail a video on their own.
|
|
37
45
|
export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
|
|
46
|
+
if (isVoiceless(plan)) return reviewVoicelessPlan(plan, ctx);
|
|
38
47
|
const issues: string[] = [];
|
|
39
48
|
const { words: total, seconds } = estimateLength(plan);
|
|
40
49
|
|
|
@@ -53,7 +62,30 @@ export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): str
|
|
|
53
62
|
// A hit of a few words is wanted in the rhythm of a video, so only a scene with almost nothing to say is flagged.
|
|
54
63
|
if (!ctx.footage && n < 3) issues.push(`Scene ${s.id} has only ${n} word${n === 1 ? "" : "s"} of narration; give it at least a short phrase.`);
|
|
55
64
|
if (n > 45) issues.push(`Scene ${s.id} has ${n} words of narration; split it or cut it to under 45.`);
|
|
56
|
-
if (s.onScreenText.length >
|
|
65
|
+
if (s.onScreenText.length > 5) issues.push(`Scene ${s.id} has ${s.onScreenText.length} on-screen text items; use at most 5 (the items of one list on screen count as one text, but the plan cannot say which they are).`);
|
|
66
|
+
for (const t of s.onScreenText) if (words(t) > 7) issues.push(`Scene ${s.id}: on-screen text "${t}" is too long; keep each item to 7 words or fewer.`);
|
|
67
|
+
}
|
|
68
|
+
const rhythm = rhythmNote(plan);
|
|
69
|
+
if (rhythm) issues.push(rhythm);
|
|
70
|
+
issues.push(...structureNotes(plan, ctx));
|
|
71
|
+
return issues;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The same kind of advice for a video with no voice: no words to count, so length and rhythm come from `seconds`, and every word the
|
|
75
|
+
// viewer reads is on screen. A video of this kind needs at least one quick hit, so a plan with no scene under a second is flagged.
|
|
76
|
+
function reviewVoicelessPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
|
|
77
|
+
const issues: string[] = [];
|
|
78
|
+
const { seconds } = estimateLength(plan);
|
|
79
|
+
if (seconds < 10) issues.push(`The video runs ${seconds.toFixed(1)}s. A video with no voice needs at least 10 seconds to get across; aim for 15 to 45.`);
|
|
80
|
+
if (seconds > 60) issues.push(`The video runs ${seconds.toFixed(1)}s. Cut it to 60 seconds or less; aim for 15 to 45.`);
|
|
81
|
+
if (!plan.scenes.some((s) => (s.seconds ?? 0) < 1)) issues.push("No scene is under 1 second. A film like this needs at least one quick hit; shorten one or two scenes to under a second.");
|
|
82
|
+
if (!ctx.footage) {
|
|
83
|
+
const illustrations = plan.scenes.filter((s) => s.treatment === "illustration").length;
|
|
84
|
+
if (illustrations > Math.ceil(plan.scenes.length / 2)) issues.push(`${illustrations} of ${plan.scenes.length} scenes are illustrations. Use illustrations for at most half the scenes.`);
|
|
85
|
+
}
|
|
86
|
+
for (const s of plan.scenes) {
|
|
87
|
+
if (!s.onScreenText.length && !s.notes.trim()) issues.push(`Scene ${s.id} has neither onScreenText nor notes. Say what is on screen, since no voice explains it.`);
|
|
88
|
+
if (s.onScreenText.length > 5) issues.push(`Scene ${s.id} has ${s.onScreenText.length} on-screen text items; use at most 5 (the items of one list on screen count as one text, but the plan cannot say which they are).`);
|
|
57
89
|
for (const t of s.onScreenText) if (words(t) > 7) issues.push(`Scene ${s.id}: on-screen text "${t}" is too long; keep each item to 7 words or fewer.`);
|
|
58
90
|
}
|
|
59
91
|
const rhythm = rhythmNote(plan);
|
|
@@ -75,14 +107,16 @@ export function firstSentence(text: string): string {
|
|
|
75
107
|
export function structureNotes(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
|
|
76
108
|
const notes: string[] = [];
|
|
77
109
|
if (ctx.footage) return notes;
|
|
78
|
-
|
|
110
|
+
// A film with no voice is made of type and interface pieces on purpose (reference/launch-film.md), so it is not asked for a picture.
|
|
111
|
+
const voiceless = isVoiceless(plan);
|
|
112
|
+
if (!voiceless && plan.scenes.length >= 4 && !plan.scenes.some(hasPicture)) {
|
|
79
113
|
notes.push("Every scene is type and shapes: no illustration, no clip and none of the user's own files. Give at least one scene a picture so the video has something to look at; see reference/scene-treatments.md.");
|
|
80
114
|
}
|
|
81
115
|
const first = plan.scenes[0]!;
|
|
82
|
-
if (!hasPicture(first)) {
|
|
116
|
+
if (!voiceless && !hasPicture(first)) {
|
|
83
117
|
notes.push(`The opening has no picture, clip or user asset (scene ${first.id}). The first second decides whether the video is watched, so open on something to look at; see reference/scriptwriting.md.`);
|
|
84
118
|
}
|
|
85
|
-
const n = words(firstSentence(first.narration));
|
|
119
|
+
const n = isVoiceless(plan) ? 0 : words(firstSentence(first.narration));
|
|
86
120
|
if (n > 12) notes.push(`The first sentence of the opening is ${n} words. Open with a sentence of 12 words or fewer; see reference/scriptwriting.md.`);
|
|
87
121
|
return notes;
|
|
88
122
|
}
|