reelkit-cli 0.4.0 → 0.5.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 +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +8 -2
- package/skill/reference/beat-sync.md +45 -0
- package/skill/reference/captions.md +18 -2
- package/skill/reference/continuity.md +99 -0
- package/skill/reference/kit.md +18 -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 +2 -2
- package/src/commands/assets.ts +25 -2
- package/src/commands/build.ts +73 -8
- package/src/commands/plan.ts +4 -2
- package/src/commands/ref.ts +11 -3
- package/src/pipeline/beatsnap.ts +52 -0
- package/src/pipeline/review.ts +28 -1
- package/src/pipeline/schema.ts +8 -0
- package/src/project/manifest.ts +22 -1
- package/src/project/music.ts +25 -0
- package/src/project/project.ts +1 -1
- package/src/project/refmeasure.ts +44 -0
- 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/beat.ts +23 -0
- package/src/remotion/kit/caption-groups.ts +65 -0
- package/src/remotion/kit/docs.ts +18 -1
- package/src/remotion/kit/index.ts +7 -0
- package/src/remotion/kit/media.ts +15 -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/validate.ts +27 -0
package/src/commands/build.ts
CHANGED
|
@@ -3,12 +3,14 @@ import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import type { Ctx, Result } from "../context";
|
|
6
|
-
import type { AssetManifest } from "../pipeline/schema";
|
|
6
|
+
import type { AssetManifest, ScenePlan } from "../pipeline/schema";
|
|
7
|
+
import { compareFrames, SAMPLE_WIDTH, summariseContinuity, type Boundary } from "../render/continuity";
|
|
7
8
|
import { buildManifest, missingAssets } from "../project/manifest";
|
|
9
|
+
import { beatReport } from "../project/music";
|
|
8
10
|
import { openProject, type Project } from "../project/project";
|
|
9
11
|
import { mediaUrls, serveDir } from "../project/serve";
|
|
10
12
|
import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
|
|
11
|
-
import { FILE_NAME, MAIN_FILE, staticCheck, typecheck } from "../render/validate";
|
|
13
|
+
import { FILE_NAME, MAIN_FILE, jsxUses, staticCheck, typecheck } from "../render/validate";
|
|
12
14
|
import { loadPlan } from "./plan";
|
|
13
15
|
|
|
14
16
|
const exec = promisify(execFile);
|
|
@@ -36,11 +38,28 @@ function inspect(project: Project): { errors: string[]; manifest?: AssetManifest
|
|
|
36
38
|
return manifest ? { errors: [], manifest } : { errors: ["The manifest could not be built. Run `reelkit assets voiceover --all`."] };
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
// Advice about a composition that passes: never an error.
|
|
42
|
+
function compositionNotes(project: Project, plan: ScenePlan): string[] {
|
|
43
|
+
const notes: string[] = [];
|
|
44
|
+
const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f));
|
|
45
|
+
const sources = names.map((n) => [n, readFileSync(project.path(join("src", n)), "utf8")] as const);
|
|
46
|
+
const rendersCaptions = sources.some(([n, src]) => jsxUses(src, n, "Captions").length > 0);
|
|
47
|
+
if (plan.captions === "none" && rendersCaptions) notes.push('The plan says captions: "none" but the composition renders <Captions>. Remove them, or change the plan if the user wants captions.');
|
|
48
|
+
if ((plan.captions === "word" || plan.captions === "phrase") && !rendersCaptions) notes.push(`The plan asks for captions (${plan.captions}) but the composition renders no <Captions>. Add <Captions words={s.words} group={manifest.captions} /> in each scene, or set captions to "none" in the plan if the user does not want them.`);
|
|
49
|
+
return notes;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
|
|
40
53
|
|
|
41
54
|
export async function check(ctx: Ctx): Promise<Result> {
|
|
42
|
-
const
|
|
43
|
-
|
|
55
|
+
const project = openProject(ctx.cwd);
|
|
56
|
+
const { errors } = inspect(project);
|
|
57
|
+
if (errors.length) return failed(errors);
|
|
58
|
+
const shouldImprove = compositionNotes(project, loadPlan(project));
|
|
59
|
+
return {
|
|
60
|
+
ok: true, data: { errors: [], shouldImprove },
|
|
61
|
+
summary: ["The composition passes. Run `reelkit preview` to see it.", ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : [])].join("\n"),
|
|
62
|
+
};
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
// Bundles the composition and serves the project's media for as long as fn runs.
|
|
@@ -72,7 +91,7 @@ export async function ensureBrowserWithNotice(ctx: Ctx, ensure: EnsureBrowser =
|
|
|
72
91
|
|
|
73
92
|
const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
|
|
74
93
|
|
|
75
|
-
type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle"; frame: number; name: string };
|
|
94
|
+
type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle" | "end" | "start"; frame: number; name: string };
|
|
76
95
|
|
|
77
96
|
// Two frames per scene, at 30% and 90% of its length, so what enters late in a scene is seen as well as what opens it.
|
|
78
97
|
// A scene under 4 frames has no room for two distinct frames and gets its middle one. The scene's number leads each name
|
|
@@ -86,6 +105,30 @@ export function previewPoints(scenes: { id: string; startFrame: number; duration
|
|
|
86
105
|
});
|
|
87
106
|
}
|
|
88
107
|
|
|
108
|
+
type BoundaryPoint = { from: string; to: string; end: PreviewPoint; start: PreviewPoint };
|
|
109
|
+
|
|
110
|
+
// SceneFrame fades each scene in and out over up to 8 frames, so the very last and very first frames of two scenes are nearly empty.
|
|
111
|
+
// The frames that show what a scene really holds are the last one at full strength and the first one at full strength: those are the two
|
|
112
|
+
// either side of every change. The files are named by the change, b01-end-<scene> then b01-start-<scene>, so that they sort in order.
|
|
113
|
+
export function boundaryPoints(scenes: { id: string; startFrame: number; durationFrames: number }[]): BoundaryPoint[] {
|
|
114
|
+
const edge = (d: number) => Math.max(1, Math.min(8, Math.floor(d / 2)));
|
|
115
|
+
return scenes.slice(0, -1).map((a, i) => {
|
|
116
|
+
const b = scenes[i + 1]!;
|
|
117
|
+
const n = `b${String(i + 1).padStart(2, "0")}`;
|
|
118
|
+
return {
|
|
119
|
+
from: a.id, to: b.id,
|
|
120
|
+
end: { sceneId: a.id, point: "end", frame: a.startFrame + Math.max(0, a.durationFrames - edge(a.durationFrames)), name: `${n}-end-${a.id}` },
|
|
121
|
+
start: { sceneId: b.id, point: "start", frame: b.startFrame + Math.min(b.durationFrames - 1, edge(b.durationFrames)), name: `${n}-start-${b.id}` },
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A small grey copy of a frame as plain pixels, for the continuity measurement.
|
|
127
|
+
async function decodeGray(png: string, width: number, height: number): Promise<Uint8Array> {
|
|
128
|
+
const { stdout } = await exec("ffmpeg", ["-v", "error", "-i", png, "-vf", `scale=${width}:${height}:flags=area,format=gray`, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024 });
|
|
129
|
+
return new Uint8Array(stdout);
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void>; ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
|
|
90
133
|
const toJpeg = deps.toJpeg ?? ffmpegToJpeg;
|
|
91
134
|
const project = openProject(ctx.cwd);
|
|
@@ -94,11 +137,13 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
94
137
|
const dir = project.path("out/preview");
|
|
95
138
|
rmSync(dir, { recursive: true, force: true });
|
|
96
139
|
mkdirSync(dir, { recursive: true });
|
|
97
|
-
const
|
|
140
|
+
const sceneFrames = previewPoints(manifest.scenes);
|
|
141
|
+
const boundaries = boundaryPoints(manifest.scenes);
|
|
142
|
+
const points = [...sceneFrames, ...boundaries.flatMap((b) => [b.end, b.start])];
|
|
98
143
|
try {
|
|
99
144
|
try {
|
|
100
145
|
await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
|
|
101
|
-
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, points.map((p) => p.frame), dir));
|
|
146
|
+
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, [...new Set(points.map((p) => p.frame))], dir));
|
|
102
147
|
} catch (e) {
|
|
103
148
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
104
149
|
}
|
|
@@ -113,7 +158,27 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
113
158
|
} catch {
|
|
114
159
|
return failed(["Could not write the preview frames: ffmpeg failed. Check that ffmpeg is installed (macOS: `brew install ffmpeg`) and run `reelkit preview` again."]);
|
|
115
160
|
}
|
|
116
|
-
|
|
161
|
+
// Whether anything survives each scene change is advice. If it cannot be measured, the preview is still the preview.
|
|
162
|
+
let continuity: { score: number | null; boundaries: Boundary[] } | undefined;
|
|
163
|
+
let continuityLines: string[] = [];
|
|
164
|
+
try {
|
|
165
|
+
const h = Math.round((SAMPLE_WIDTH * manifest.height) / manifest.width);
|
|
166
|
+
const measured: Boundary[] = [];
|
|
167
|
+
for (const b of boundaries) {
|
|
168
|
+
const [a, c] = await Promise.all([decodeGray(join(dir, `still-${b.end.frame}.png`), SAMPLE_WIDTH, h), decodeGray(join(dir, `still-${b.start.frame}.png`), SAMPLE_WIDTH, h)]);
|
|
169
|
+
const r = compareFrames(a, c, SAMPLE_WIDTH, h);
|
|
170
|
+
measured.push({ from: b.from, to: b.to, survived: Math.round(r.survived * 100) / 100, kind: r.kind });
|
|
171
|
+
}
|
|
172
|
+
if (measured.length) {
|
|
173
|
+
const summary = summariseContinuity(measured);
|
|
174
|
+
continuity = { score: summary.score === null ? null : Math.round(summary.score * 100) / 100, boundaries: measured };
|
|
175
|
+
continuityLines = summary.lines;
|
|
176
|
+
}
|
|
177
|
+
} catch { /* no continuity report */ }
|
|
178
|
+
const sceneLines = `Two frames per scene, at 30% and at 90% of its length (a scene under 4 frames gets its middle one). Look at both frames of every scene:\n${frames.filter((f) => f.point !== "end" && f.point !== "start").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}`;
|
|
179
|
+
const changeLines = boundaries.length ? `\nThe last and first frames either side of each scene change are saved too (b01-end-<scene>, b01-start-<scene>, ...), so that you can see what carries across:\n${frames.filter((f) => f.point === "end" || f.point === "start").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}` : "";
|
|
180
|
+
const beat = beatReport(manifest);
|
|
181
|
+
return { ok: true, data: { frames, ...(continuity ? { continuity } : {}), ...(beat ? { beat: { bpm: beat.bpm, boundariesOnBeat: beat.boundariesOnBeat, boundaries: beat.boundaries } } : {}) }, summary: `${sceneLines}${changeLines}${continuityLines.length ? `\nContinuity: ${continuityLines.join("\n")}` : ""}${beat ? `\nBeat: ${beat.line}.` : ""}` };
|
|
117
182
|
} finally {
|
|
118
183
|
// The full-size stills are only inputs; none stays behind however this ends.
|
|
119
184
|
for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
|
package/src/commands/plan.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { client, type Ctx, type Result } from "../context";
|
|
2
2
|
import { loadCredentials } from "../credentials";
|
|
3
|
-
import { estimateLength, reviewPlan } from "../pipeline/review";
|
|
3
|
+
import { estimateLength, lengthVariation, reviewPlan, sceneSeconds } from "../pipeline/review";
|
|
4
4
|
import { ScenePlanSchema, validatePlan, type ScenePlan } from "../pipeline/schema";
|
|
5
5
|
import { FILES, issueLines, openProject, type Project } from "../project/project";
|
|
6
6
|
|
|
@@ -58,11 +58,13 @@ export async function planCheck(ctx: Ctx): Promise<Result> {
|
|
|
58
58
|
const shouldImprove = mustFix.length ? [] : [...reviewPlan(parsed.data, { footage }), ...ref.improve];
|
|
59
59
|
const length = estimateLength(parsed.data);
|
|
60
60
|
const estimatedSeconds = Math.round(length.seconds);
|
|
61
|
+
const perScene = sceneSeconds(parsed.data).map((s) => ({ id: s.id, seconds: Math.round(s.seconds * 10) / 10 }));
|
|
62
|
+
const variation = Math.round(lengthVariation(sceneSeconds(parsed.data).map((s) => s.seconds)) * 100) / 100;
|
|
61
63
|
const lines = [
|
|
62
64
|
mustFix.length ? `Fix these:\n- ${mustFix.join("\n- ")}` : "The plan is valid.",
|
|
63
65
|
`Estimated length: about ${estimatedSeconds}s (${length.words} words).`,
|
|
64
66
|
...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : []),
|
|
65
67
|
...(voiceIds ? [] : ["The voice was not checked because you are not logged in. Run `reelkit auth login`."]),
|
|
66
68
|
];
|
|
67
|
-
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words }, summary: lines.join("\n") };
|
|
69
|
+
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words, sceneSeconds: perScene, lengthVariation: variation }, summary: lines.join("\n") };
|
|
68
70
|
}
|
package/src/commands/ref.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { loadCredentials } from "../credentials";
|
|
|
10
10
|
import { analyzeBeats, cutsOnBeat } from "../project/beats";
|
|
11
11
|
import { openProject, type Project } from "../project/project";
|
|
12
12
|
import {
|
|
13
|
-
audioDuration, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, toMp4,
|
|
13
|
+
audioDuration, coefficientOfVariation, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, stillnessOfVideo, toMp4,
|
|
14
14
|
} from "../project/refmeasure";
|
|
15
15
|
|
|
16
16
|
// A reference is for learning how a video is built, so ten minutes is more than it needs and keeps every step quick.
|
|
@@ -29,7 +29,11 @@ export type Breakdown = {
|
|
|
29
29
|
scenes: { index: number; startSec: number; endSec: number; durationSec: number; frames: string[]; palette: string[] }[];
|
|
30
30
|
// True when the video has more cuts than the 40 scenes listed: the last scene then holds everything after the 39th cut.
|
|
31
31
|
scenesCapped?: boolean;
|
|
32
|
-
pacing: { cuts: number; averageShotSec: number; shortestShotSec: number; longestShotSec: number; cutsPerSecond: number; firstCutSec?: number; cutsOnBeat?: number
|
|
32
|
+
pacing: { cuts: number; averageShotSec: number; shortestShotSec: number; longestShotSec: number; cutsPerSecond: number; firstCutSec?: number; cutsOnBeat?: number;
|
|
33
|
+
// How uneven the shot lengths are (standard deviation over the mean); left out with fewer than 3 shots. High means a quarter-second hit beside a long hold.
|
|
34
|
+
shotLengthVariation?: number };
|
|
35
|
+
// The share of the video's time in which the picture barely changes from frame to frame, and the longest such stretch. Missing from breakdowns saved by older versions.
|
|
36
|
+
stillness?: { share: number; longestSec: number };
|
|
33
37
|
palette: string[];
|
|
34
38
|
audio: { hasAudio: boolean; loudnessLufs?: number; tempoBpm?: number; beatConfidence: number };
|
|
35
39
|
beats?: number[];
|
|
@@ -245,7 +249,10 @@ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Brea
|
|
|
245
249
|
const pacing: Breakdown["pacing"] = {
|
|
246
250
|
cuts: cuts.length, averageShotSec: round2(ref.durationSec / shots.length), shortestShotSec: round2(Math.min(...shots)), longestShotSec: round2(Math.max(...shots)),
|
|
247
251
|
cutsPerSecond: round2(cuts.length / ref.durationSec), ...(cuts.length ? { firstCutSec: round2(cuts[0]!) } : {}),
|
|
252
|
+
...(shots.length >= 3 ? { shotLengthVariation: round2(coefficientOfVariation(shots)) } : {}),
|
|
248
253
|
};
|
|
254
|
+
ctx.log("Measuring how much of the time the picture is still.");
|
|
255
|
+
const still = await stillnessOfVideo(video, ref.width, ref.height);
|
|
249
256
|
|
|
250
257
|
const audio: Breakdown["audio"] = { hasAudio: ref.hasAudio, beatConfidence: 0 };
|
|
251
258
|
let beats: number[] | undefined;
|
|
@@ -264,7 +271,7 @@ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Brea
|
|
|
264
271
|
}
|
|
265
272
|
} catch { /* no tempo */ }
|
|
266
273
|
}
|
|
267
|
-
return { source: ref.source, durationSec: ref.durationSec, width: ref.width, height: ref.height, aspect: ref.aspect, fps: ref.fps, scenes, ...(cuts.length > MAX_SCENES - 1 ? { scenesCapped: true } : {}), pacing, palette, audio, ...(beats ? { beats } : {}) };
|
|
274
|
+
return { source: ref.source, durationSec: ref.durationSec, width: ref.width, height: ref.height, aspect: ref.aspect, fps: ref.fps, scenes, ...(cuts.length > MAX_SCENES - 1 ? { scenesCapped: true } : {}), pacing, stillness: still, palette, audio, ...(beats ? { beats } : {}) };
|
|
268
275
|
}
|
|
269
276
|
|
|
270
277
|
// A short text a person or an agent can read at once.
|
|
@@ -274,6 +281,7 @@ function digest(id: string, b: Breakdown, notes: string[]): string {
|
|
|
274
281
|
b.pacing.cuts
|
|
275
282
|
? `Shape: ${b.scenes.length}${b.scenesCapped ? "+" : ""} scenes, ${b.pacing.cuts} cuts; average shot ${fmt(b.pacing.averageShotSec)}s (shortest ${fmt(b.pacing.shortestShotSec)}s, longest ${fmt(b.pacing.longestShotSec)}s); first cut at ${fmt(b.pacing.firstCutSec!)}s.`
|
|
276
283
|
: "Shape: one continuous shot, no cuts found.",
|
|
284
|
+
...(b.stillness ? [`Still ${Math.round(b.stillness.share * 100)}% of the time; longest hold ${fmt(b.stillness.longestSec)} s.${b.pacing.shotLengthVariation !== undefined ? ` Shot lengths vary by ${b.pacing.shotLengthVariation} (the spread over the average).` : ""}`] : []),
|
|
277
285
|
`Palette: ${b.palette.join(", ")}.`,
|
|
278
286
|
!b.audio.hasAudio ? "Audio: none."
|
|
279
287
|
: `Audio:${b.audio.loudnessLufs !== undefined ? ` ${b.audio.loudnessLufs} LUFS;` : ""} ${b.audio.tempoBpm !== undefined ? `tempo about ${Math.round(b.audio.tempoBpm)} BPM (confidence ${b.audio.beatConfidence})` : "no clear beat"}${b.pacing.cutsOnBeat !== undefined ? `; ${Math.round(b.pacing.cutsOnBeat * b.pacing.cuts)} of ${b.pacing.cuts} cuts land on a beat` : ""}.`,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Puts scene changes on the beat. A scene is held a little longer at its end so that the next one starts exactly on a beat.
|
|
2
|
+
// The voiceover and its word times never move: only a scene's length, and so where the later scenes start, changes.
|
|
3
|
+
|
|
4
|
+
// The last scene must keep at least this long after its last word, as the natural padding already does (0.4 s) plus a little.
|
|
5
|
+
export const MIN_TAIL_SEC = 0.5;
|
|
6
|
+
|
|
7
|
+
export type SnapInput = {
|
|
8
|
+
// Each scene's natural length in frames.
|
|
9
|
+
naturalFrames: number[];
|
|
10
|
+
// When the last word of the last scene ends, in frames from the start of that scene.
|
|
11
|
+
lastWordEndFrame: number;
|
|
12
|
+
// Beats as frame numbers, ascending. They should reach past the end of the video.
|
|
13
|
+
beatFrames: number[];
|
|
14
|
+
fps: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const firstAtOrAfter = (beats: number[], frame: number): number | undefined => beats.find((b) => b >= frame);
|
|
18
|
+
|
|
19
|
+
// The new length of every scene. A scene is only ever made longer, by less than one beat (the last by a few frames more, to keep its tail).
|
|
20
|
+
// A boundary that has no beat close enough keeps its natural place, and the scenes after it follow from there.
|
|
21
|
+
export function snapToBeats(input: SnapInput): number[] {
|
|
22
|
+
const { naturalFrames, beatFrames, fps } = input;
|
|
23
|
+
if (beatFrames.length < 2) return [...naturalFrames];
|
|
24
|
+
const gaps = beatFrames.slice(1).map((b, i) => b - beatFrames[i]!).sort((a, b) => a - b);
|
|
25
|
+
const period = Math.max(1, gaps[Math.floor(gaps.length / 2)]!);
|
|
26
|
+
let cursor = 0;
|
|
27
|
+
return naturalFrames.map((natural, i) => {
|
|
28
|
+
const last = i === naturalFrames.length - 1;
|
|
29
|
+
let end = cursor + natural;
|
|
30
|
+
let limit = period;
|
|
31
|
+
if (last) {
|
|
32
|
+
const tail = cursor + input.lastWordEndFrame + Math.ceil(MIN_TAIL_SEC * fps);
|
|
33
|
+
if (tail > end) { limit += tail - end; end = tail; }
|
|
34
|
+
}
|
|
35
|
+
const beat = firstAtOrAfter(beatFrames, end);
|
|
36
|
+
const target = beat !== undefined && beat - (cursor + natural) <= limit ? beat : end;
|
|
37
|
+
const frames = target - cursor;
|
|
38
|
+
cursor = target;
|
|
39
|
+
return frames;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Beats in seconds, continued at the tempo's own spacing until `untilSec`, for a video longer than the track (the track loops).
|
|
44
|
+
export function extendBeats(beats: number[], bpm: number, untilSec: number): number[] {
|
|
45
|
+
if (!beats.length) return [];
|
|
46
|
+
const period = 60 / bpm;
|
|
47
|
+
const out = [...beats];
|
|
48
|
+
while (out[out.length - 1]! < untilSec) out.push(Math.round((out[out.length - 1]! + period) * 1000) / 1000);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const toBeatFrames = (beatsSec: number[], fps: number): number[] => [...new Set(beatsSec.map((t) => Math.round(t * fps)))].sort((a, b) => a - b);
|
package/src/pipeline/review.ts
CHANGED
|
@@ -8,6 +8,30 @@ export function estimateLength(plan: ScenePlan): { words: number; seconds: numbe
|
|
|
8
8
|
return { words: total, seconds: total / WORDS_PER_SEC };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// Each scene's estimated length in seconds, from its narration at the same pace as the whole-video estimate above.
|
|
12
|
+
export function sceneSeconds(plan: ScenePlan): { id: string; seconds: number }[] {
|
|
13
|
+
return plan.scenes.map((s) => ({ id: s.id, seconds: words(s.narration) / WORDS_PER_SEC }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// How uneven the scene lengths are: the standard deviation over the mean (0 when they are all the same).
|
|
17
|
+
export function lengthVariation(seconds: number[]): number {
|
|
18
|
+
const mean = seconds.reduce((a, b) => a + b, 0) / (seconds.length || 1);
|
|
19
|
+
if (!(mean > 0)) return 0;
|
|
20
|
+
return Math.sqrt(seconds.reduce((a, b) => a + (b - mean) ** 2, 0) / seconds.length) / mean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A video whose scenes all last about as long feels like a slideshow. With four scenes or more, the lengths should differ a lot.
|
|
24
|
+
export function rhythmNote(plan: ScenePlan): string | undefined {
|
|
25
|
+
if (plan.scenes.length < 4) return undefined;
|
|
26
|
+
const lengths = sceneSeconds(plan);
|
|
27
|
+
const values = lengths.map((l) => l.seconds);
|
|
28
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
29
|
+
const cv = lengthVariation(values);
|
|
30
|
+
if (cv >= 0.25 && values.some((v) => v < mean / 2)) return undefined;
|
|
31
|
+
const shortest = lengths.reduce((a, b) => (b.seconds < a.seconds ? b : a)), longest = lengths.reduce((a, b) => (b.seconds > a.seconds ? b : a));
|
|
32
|
+
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). Make one or two scenes much shorter (a hit of a few words) and let one run long.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
// Soft quality checks on a plan that already passes the hard rules in validatePlan.
|
|
12
36
|
// These are things worth improving in the script; they never fail a video on their own.
|
|
13
37
|
export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
|
|
@@ -26,10 +50,13 @@ export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): str
|
|
|
26
50
|
|
|
27
51
|
for (const s of plan.scenes) {
|
|
28
52
|
const n = words(s.narration);
|
|
29
|
-
|
|
53
|
+
// 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
|
+
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.`);
|
|
30
55
|
if (n > 45) issues.push(`Scene ${s.id} has ${n} words of narration; split it or cut it to under 45.`);
|
|
31
56
|
if (s.onScreenText.length > 3) issues.push(`Scene ${s.id} has ${s.onScreenText.length} on-screen text items; use at most 3.`);
|
|
32
57
|
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.`);
|
|
33
58
|
}
|
|
59
|
+
const rhythm = rhythmNote(plan);
|
|
60
|
+
if (rhythm) issues.push(rhythm);
|
|
34
61
|
return issues;
|
|
35
62
|
}
|
package/src/pipeline/schema.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const ScenePlanSchema = z.object({
|
|
|
28
28
|
// Optional only so plans stored before voices existed still load; a new plan must choose one.
|
|
29
29
|
voiceId: z.string().optional().describe("id of the narration voice, one of the ids from `reelkit assets voices`, chosen to suit the idea, audience and language"),
|
|
30
30
|
pace: z.enum(["slow", "normal", "fast"]).optional().describe("speaking pace: slow for calm or emotional, normal by default, fast for high-energy"),
|
|
31
|
+
// Whether the video has captions and of which kind: none, one word at a time, or a phrase at a time. Absent means "phrase".
|
|
32
|
+
captions: z.enum(["none", "word", "phrase"]).optional().describe("captions: none, one word at a time (word), or a phrase at a time (phrase, the default), as the user chose"),
|
|
31
33
|
// A video the new one is made "like": what is taken from it is how it feels (structure, pacing, motion), never its footage, music or words.
|
|
32
34
|
// Optional, so plans stored before references existed still load.
|
|
33
35
|
reference: z.object({
|
|
@@ -83,6 +85,12 @@ export const AssetManifestSchema = z.object({
|
|
|
83
85
|
height: z.number(),
|
|
84
86
|
totalFrames: z.number(),
|
|
85
87
|
footageKey: z.string().optional(),
|
|
88
|
+
// The video's music track, when one was pulled with `reelkit assets pull <id> --music`. beatFrames are the beats that fall inside the video.
|
|
89
|
+
music: z.object({ key: z.string(), bpm: z.number().optional(), beatFrames: z.array(z.number()) }).optional(),
|
|
90
|
+
// The plan's caption choice, with the default filled in, so the composition can pass it to <Captions group={...}>.
|
|
91
|
+
captions: z.enum(["none", "word", "phrase"]).optional(),
|
|
92
|
+
// Linear gain per sound file path that brings every pulled sound to a common level; the kit's Sfx and Music apply it.
|
|
93
|
+
soundGain: z.record(z.string(), z.number()).optional(),
|
|
86
94
|
scenes: z.array(ManifestSceneSchema),
|
|
87
95
|
});
|
|
88
96
|
export type AssetManifest = z.infer<typeof AssetManifestSchema>;
|
package/src/project/manifest.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { PACE_SPEED, type AssetManifest, type ScenePlan, type WordTiming } from "../pipeline/schema";
|
|
2
|
+
import { extendBeats, snapToBeats, toBeatFrames } from "../pipeline/beatsnap";
|
|
2
3
|
import { dimensionsFor, FPS, layoutScenes } from "../pipeline/timing";
|
|
4
|
+
import type { MusicRecord } from "./music";
|
|
3
5
|
import { FILES, type Project } from "./project";
|
|
4
6
|
|
|
5
7
|
// What a scene was recorded from is stored with it, so a changed script or voice is noticed.
|
|
@@ -24,7 +26,22 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
|
|
|
24
26
|
const footage = project.footage();
|
|
25
27
|
const byId = new Map(project.assets().map((a) => [a.id, a]));
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
let layout = layoutScenes(plan.scenes.map((s) => voiceovers[s.id].durationSec));
|
|
30
|
+
const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
|
|
31
|
+
let beatFrames: number[] = [];
|
|
32
|
+
if (music) {
|
|
33
|
+
beatFrames = toBeatFrames(extendBeats(music.beats, music.bpm ?? 120, layout.totalFrames / FPS + 10), FPS);
|
|
34
|
+
// Scene changes land on beats only when the track has a trusted tempo. Footage has a fixed length that a longer scene must not overrun.
|
|
35
|
+
if (music.bpm && music.beats.length) {
|
|
36
|
+
const last = voiceovers[plan.scenes[plan.scenes.length - 1].id];
|
|
37
|
+
const lastWords = last.words.length ? Math.max(...last.words.map((w) => w.endSec)) : last.durationSec;
|
|
38
|
+
const lengths = snapToBeats({ naturalFrames: layout.scenes.map((s) => s.durationFrames), lastWordEndFrame: Math.ceil(lastWords * FPS), beatFrames, fps: FPS });
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
const snapped = { scenes: lengths.map((durationFrames) => { const r = { startFrame: cursor, durationFrames }; cursor += durationFrames; return r; }), totalFrames: 0 };
|
|
41
|
+
snapped.totalFrames = cursor;
|
|
42
|
+
if (!footage || cursor <= Math.floor((footage.durationSec ?? 0) * FPS)) layout = snapped;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
28
45
|
let totalFrames = layout.totalFrames;
|
|
29
46
|
if (footage) {
|
|
30
47
|
const footageFrames = Math.floor((footage.durationSec ?? 0) * FPS);
|
|
@@ -38,7 +55,9 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
|
|
|
38
55
|
fps: FPS,
|
|
39
56
|
...dimensionsFor(plan.aspect, footage),
|
|
40
57
|
totalFrames,
|
|
58
|
+
captions: plan.captions ?? "phrase",
|
|
41
59
|
...(footage ? { footageKey: footage.key } : {}),
|
|
60
|
+
...(music ? { music: { key: music.key, ...(music.bpm ? { bpm: music.bpm } : {}), beatFrames: music.bpm ? beatFrames.filter((f) => f < totalFrames) : [] } } : {}),
|
|
42
61
|
scenes: plan.scenes.map((scene, i) => ({
|
|
43
62
|
id: scene.id,
|
|
44
63
|
startFrame: layout.scenes[i].startFrame,
|
|
@@ -64,6 +83,8 @@ export function missingAssets(project: Project, plan: ScenePlan): string[] {
|
|
|
64
83
|
const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
|
|
65
84
|
const clips = project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {});
|
|
66
85
|
const problems: string[] = [];
|
|
86
|
+
const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
|
|
87
|
+
if (music && !project.exists(music.key)) problems.push(`Missing file: ${music.key}. Pull the track again with \`reelkit assets pull ${music.id} --music\`.`);
|
|
67
88
|
for (const s of plan.scenes) {
|
|
68
89
|
if (!voiceovers[s.id]) problems.push(`Scene ${s.id} has no voiceover.`);
|
|
69
90
|
else if (voiceoverStale(voiceovers[s.id], s, plan)) problems.push(`Scene ${s.id}'s voiceover is out of date. Run \`reelkit assets voiceover --all\`.`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AssetManifest } from "../pipeline/schema";
|
|
2
|
+
import { analyzeBeats } from "./beats";
|
|
3
|
+
import { audioDuration, decodeMono } from "./refmeasure";
|
|
4
|
+
|
|
5
|
+
// The video's one music track, stored in assets/music.json. `bpm` and `beats` are present only when the pulse was clear enough to trust.
|
|
6
|
+
export type MusicRecord = { key: string; id: string; title: string; durationSec: number; bpm?: number; beatConfidence: number; beats: number[]; gainDb: number };
|
|
7
|
+
|
|
8
|
+
const RATE = 11025;
|
|
9
|
+
|
|
10
|
+
// Measures a pulled track on this machine: its length, its tempo and where its beats fall.
|
|
11
|
+
export async function measureMusic(path: string): Promise<Pick<MusicRecord, "durationSec" | "bpm" | "beatConfidence" | "beats">> {
|
|
12
|
+
const durationSec = Math.round((await audioDuration(path)) * 1000) / 1000;
|
|
13
|
+
const r = analyzeBeats(await decodeMono(path, RATE), RATE);
|
|
14
|
+
return { durationSec, beatConfidence: r.confidence, beats: r.tempoBpm !== undefined && r.beats ? r.beats : [], ...(r.tempoBpm !== undefined ? { bpm: r.tempoBpm } : {}) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// How many scene changes fall on a beat, for the preview.
|
|
18
|
+
export function beatReport(manifest: AssetManifest): { bpm: number; boundariesOnBeat: number; boundaries: number; line: string } | undefined {
|
|
19
|
+
const m = manifest.music;
|
|
20
|
+
if (!m?.bpm || manifest.scenes.length < 2) return undefined;
|
|
21
|
+
const beats = new Set(m.beatFrames);
|
|
22
|
+
const starts = manifest.scenes.slice(1).map((s) => s.startFrame);
|
|
23
|
+
const on = starts.filter((f) => beats.has(f)).length;
|
|
24
|
+
return { bpm: m.bpm, boundariesOnBeat: on, boundaries: starts.length, line: `${on} of ${starts.length} scene changes land on the beat at ${Math.round(m.bpm)} BPM` };
|
|
25
|
+
}
|
package/src/project/project.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { AspectSchema, type AssetRecord } from "../pipeline/schema";
|
|
|
5
5
|
|
|
6
6
|
export const FILES = {
|
|
7
7
|
config: "reelkit.json", plan: "plan.json", manifest: "manifest.json",
|
|
8
|
-
assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json",
|
|
8
|
+
assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json", music: "assets/music.json",
|
|
9
9
|
} as const;
|
|
10
10
|
|
|
11
11
|
// "path: message", or just the message for a problem at the root of the file.
|
|
@@ -146,3 +146,47 @@ export async function audioDuration(path: string): Promise<number> {
|
|
|
146
146
|
const { stdout } = await tool("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]);
|
|
147
147
|
return Number(stdout.toString("utf8").trim());
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
// The mean absolute difference, from 0 to 255, between each frame of a run of grey frames and the next. frameSize is the pixels in one frame.
|
|
151
|
+
export function meanAbsDiffs(frames: Uint8Array, frameSize: number): number[] {
|
|
152
|
+
const count = Math.floor(frames.length / frameSize);
|
|
153
|
+
const out: number[] = [];
|
|
154
|
+
for (let f = 0; f + 1 < count; f++) {
|
|
155
|
+
let sum = 0;
|
|
156
|
+
for (let i = 0, a = f * frameSize, b = a + frameSize; i < frameSize; i++) sum += Math.abs(frames[a + i]! - frames[b + i]!);
|
|
157
|
+
out.push(sum / frameSize);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Two frames whose pictures differ by less than this, on average over 255 grey levels, are the same picture for a viewer: video
|
|
163
|
+
// compression noise on a held shot stays well under it, and a slow push still stays above it.
|
|
164
|
+
const STILL_BELOW = 1;
|
|
165
|
+
|
|
166
|
+
// The share of the video's time in which the picture barely changes from one sampled frame to the next, and the longest unbroken stretch
|
|
167
|
+
// of it. diffs has one entry for each pair of consecutive samples, taken `rate` times a second.
|
|
168
|
+
export function stillness(diffs: number[], rate: number): { share: number; longestSec: number } {
|
|
169
|
+
if (!diffs.length) return { share: 0, longestSec: 0 };
|
|
170
|
+
let still = 0, run = 0, longest = 0;
|
|
171
|
+
for (const d of diffs) {
|
|
172
|
+
if (d < STILL_BELOW) { still++; longest = Math.max(longest, ++run); } else run = 0;
|
|
173
|
+
}
|
|
174
|
+
const round = (n: number) => Math.round(n * 1000) / 1000;
|
|
175
|
+
return { share: round(still / diffs.length), longestSec: round(longest / rate) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Sampled at 10 frames a second and 64 pixels wide, a ten-minute video is about 44 MB of grey pixels and is measured in a second or two.
|
|
179
|
+
export const STILL_SAMPLE = { rate: 10, width: 64 };
|
|
180
|
+
|
|
181
|
+
export async function stillnessOfVideo(video: string, width: number, height: number): Promise<{ share: number; longestSec: number }> {
|
|
182
|
+
const w = STILL_SAMPLE.width, h = Math.max(2, Math.round((w * height) / width / 2) * 2);
|
|
183
|
+
const { stdout } = await tool("ffmpeg", ["-v", "error", "-i", video, "-an", "-vf", `fps=${STILL_SAMPLE.rate},scale=${w}:${h}:flags=area,format=gray`, "-f", "rawvideo", "-pix_fmt", "gray", "-"]);
|
|
184
|
+
return stillness(meanAbsDiffs(new Uint8Array(stdout.buffer, stdout.byteOffset, stdout.length), w * h), STILL_SAMPLE.rate);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The standard deviation of the values over their mean: 0 when they are all equal.
|
|
188
|
+
export function coefficientOfVariation(values: number[]): number {
|
|
189
|
+
const mean = values.reduce((a, b) => a + b, 0) / (values.length || 1);
|
|
190
|
+
if (!(mean > 0)) return 0;
|
|
191
|
+
return Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length) / mean;
|
|
192
|
+
}
|
package/src/remotion/Root.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Composition } from "remotion";
|
|
3
|
-
import { UrlsContext } from "./kit/media";
|
|
3
|
+
import { ManifestContext, UrlsContext } from "./kit/media";
|
|
4
4
|
import type { VideoProps } from "./types";
|
|
5
5
|
|
|
6
6
|
const EMPTY: VideoProps = { manifest: { fps: 30, width: 1080, height: 1920, totalFrames: 1, scenes: [] }, urls: {} };
|
|
@@ -8,7 +8,9 @@ const EMPTY: VideoProps = { manifest: { fps: 30, width: 1080, height: 1920, tota
|
|
|
8
8
|
export const makeRoot = (Video: React.FC<VideoProps>): React.FC => {
|
|
9
9
|
const WithMedia: React.FC<VideoProps> = (props) => (
|
|
10
10
|
<UrlsContext.Provider value={props.urls}>
|
|
11
|
-
<
|
|
11
|
+
<ManifestContext.Provider value={props.manifest}>
|
|
12
|
+
<Video {...props} />
|
|
13
|
+
</ManifestContext.Provider>
|
|
12
14
|
</UrlsContext.Provider>
|
|
13
15
|
);
|
|
14
16
|
return () => (
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { cameraAt, cameraTransform, type CameraKey } from "./motion-math";
|
|
4
|
+
|
|
5
|
+
// Moves the whole picture. Wrap all the scenes in it for one camera that never cuts, or wrap one scene's content.
|
|
6
|
+
// Each key gives the point of the content, as fractions of the frame, that sits at the middle of the screen (x, y), the zoom (1 shows
|
|
7
|
+
// the content as it fits) and a turn in degrees; what a key leaves out stays as it was. The move toward a key starts `lead` frames
|
|
8
|
+
// before its frame (12 unless set) on a spring and lands on the key's frame. drift is a very slow push on top, as a fraction of the
|
|
9
|
+
// zoom per second (0.02 is two percent a second), so a held shot is never perfectly still.
|
|
10
|
+
export const Camera: React.FC<{
|
|
11
|
+
keys: CameraKey[];
|
|
12
|
+
drift?: number;
|
|
13
|
+
lead?: number;
|
|
14
|
+
stiffness?: number;
|
|
15
|
+
damping?: number;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}> = ({ keys, drift, lead, stiffness, damping, children }) => {
|
|
18
|
+
const frame = useCurrentFrame();
|
|
19
|
+
const { fps, width, height } = useVideoConfig();
|
|
20
|
+
const { origin, transform } = cameraTransform(cameraAt(keys, frame, fps, { lead, stiffness, damping, drift }), width, height);
|
|
21
|
+
return <AbsoluteFill style={{ transformOrigin: origin, transform }}>{children}</AbsoluteFill>;
|
|
22
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
3
|
import type { WordTiming } from "../../pipeline/schema";
|
|
4
|
+
import { captionGroups, groupAt, type CaptionGroup } from "./caption-groups";
|
|
4
5
|
import { fonts, springs } from "./theme";
|
|
5
6
|
|
|
6
7
|
export type CaptionMode = "highlight" | "pop" | "karaoke";
|
|
@@ -9,34 +10,50 @@ export type CaptionMode = "highlight" | "pop" | "karaoke";
|
|
|
9
10
|
// highlight: a line of words, the spoken one in the highlight colour (calm, educational)
|
|
10
11
|
// pop: one to three words at a time, each popping in as it is spoken (high energy)
|
|
11
12
|
// karaoke: a line that fills with the highlight colour as it is spoken (voiceover, music)
|
|
13
|
+
// group, when given, decides which words share the screen and replaces perLine:
|
|
14
|
+
// "word": exactly one word at a time
|
|
15
|
+
// "phrase": words grouped as they are spoken: a group ends at a sentence end, at a comma or dash after three words, or at a pause of
|
|
16
|
+
// 0.35 s, and never holds more than 6 words or about 32 characters
|
|
17
|
+
// "none": nothing is drawn (the plan's `captions: "none"`), so the manifest's value can be passed straight through
|
|
12
18
|
export const Captions: React.FC<{
|
|
13
19
|
words: WordTiming[];
|
|
14
20
|
mode?: CaptionMode;
|
|
15
21
|
highlight?: string;
|
|
16
22
|
color?: string;
|
|
17
23
|
perLine?: number;
|
|
24
|
+
group?: CaptionGroup;
|
|
18
25
|
uppercase?: boolean;
|
|
19
26
|
// Distance of the caption baseline from the bottom, as a fraction of the height. Default keeps it clear of platform UI.
|
|
20
27
|
bottom?: number;
|
|
21
28
|
// Font family. Set to font("heebo") or font("rubik") for Hebrew; rtl also reverses the word order on the line.
|
|
22
29
|
face?: string;
|
|
23
30
|
rtl?: boolean;
|
|
24
|
-
}> = ({ words, mode = "highlight", highlight = "#ffe14d", color = "#ffffff", perLine, uppercase = false, bottom = 0.16, face, rtl = false }) => {
|
|
31
|
+
}> = ({ words, mode = "highlight", highlight = "#ffe14d", color = "#ffffff", perLine, group, uppercase = false, bottom = 0.16, face, rtl = false }) => {
|
|
25
32
|
const frame = useCurrentFrame();
|
|
26
33
|
const { fps, width, height } = useVideoConfig();
|
|
27
34
|
const t = frame / fps;
|
|
28
|
-
if (words.length === 0) return null;
|
|
35
|
+
if (words.length === 0 || group === "none") return null;
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
let active
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
if (group === "word" && t < words[0]!.startSec) return null;
|
|
38
|
+
let active: number, lineStart: number, line: WordTiming[];
|
|
39
|
+
if (group) {
|
|
40
|
+
const groups = captionGroups(words, group);
|
|
41
|
+
const at = groupAt(words, groups, t);
|
|
42
|
+
active = at.active;
|
|
43
|
+
[lineStart] = groups[at.index]!;
|
|
44
|
+
line = words.slice(lineStart, groups[at.index]![1]);
|
|
45
|
+
} else {
|
|
46
|
+
const size = perLine ?? (mode === "pop" ? 2 : 4);
|
|
47
|
+
active = words.findIndex((w) => t < w.endSec);
|
|
48
|
+
if (active === -1) active = words.length - 1;
|
|
49
|
+
lineStart = Math.floor(active / size) * size;
|
|
50
|
+
line = words.slice(lineStart, lineStart + size);
|
|
51
|
+
}
|
|
35
52
|
const show = (w: WordTiming) => (uppercase ? w.word.toUpperCase() : w.word);
|
|
36
53
|
|
|
37
54
|
const base: React.CSSProperties = {
|
|
38
55
|
fontFamily: face ?? fonts.body, fontWeight: 800, textAlign: "center", direction: rtl ? "rtl" : "ltr", padding: `0 ${width * 0.06}px`,
|
|
39
|
-
fontSize: width * (mode === "pop" ? 0.075 : 0.055), lineHeight: 1.15, color,
|
|
56
|
+
fontSize: width * (mode === "pop" || group === "word" ? 0.075 : 0.055), lineHeight: 1.15, color,
|
|
40
57
|
// A dark outline plus shadow keeps contrast above 4.5:1 on any background.
|
|
41
58
|
WebkitTextStroke: `${width * 0.006}px rgba(0,0,0,0.85)`, paintOrder: "stroke fill",
|
|
42
59
|
textShadow: "0 4px 18px rgba(0,0,0,0.7)",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { boxAt, type Box, type BoxKey } from "./motion-math";
|
|
4
|
+
|
|
5
|
+
// What a function child is given: the box now, as fractions of the frame, and its size in pixels.
|
|
6
|
+
export type CarryBox = Box & { widthPx: number; heightPx: number };
|
|
7
|
+
|
|
8
|
+
// One element that lives outside the scenes and is held through several of them, so that it is still on screen when a scene changes
|
|
9
|
+
// and moves, grows or turns into what the next scene needs. Place it once, beside the SceneFrames and above them.
|
|
10
|
+
// Each key says where the box must have arrived by the given absolute frame: x and y are its centre, width and height are
|
|
11
|
+
// fractions of the frame, radius is the corner radius as a fraction of the frame width, rotate is in degrees. The move toward a key
|
|
12
|
+
// starts `lead` frames before its frame (12 unless set) on a spring and lands on the key's frame exactly. Before the first key nothing is
|
|
13
|
+
// drawn unless that key sets an opacity; after the last the box holds.
|
|
14
|
+
export const Carry: React.FC<{
|
|
15
|
+
keys: BoxKey[];
|
|
16
|
+
lead?: number;
|
|
17
|
+
stiffness?: number;
|
|
18
|
+
damping?: number;
|
|
19
|
+
children: React.ReactNode | ((box: CarryBox) => React.ReactNode);
|
|
20
|
+
}> = ({ keys, lead, stiffness, damping, children }) => {
|
|
21
|
+
const frame = useCurrentFrame();
|
|
22
|
+
const { fps, width, height } = useVideoConfig();
|
|
23
|
+
const box = boxAt(keys, frame, fps, { lead, stiffness, damping });
|
|
24
|
+
if (!box || box.opacity <= 0) return null;
|
|
25
|
+
const widthPx = box.width * width, heightPx = box.height * height;
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
style={{
|
|
29
|
+
position: "absolute", left: (box.x - box.width / 2) * width, top: (box.y - box.height / 2) * height, width: widthPx, height: heightPx,
|
|
30
|
+
opacity: box.opacity, transform: box.rotate ? `rotate(${box.rotate}deg)` : undefined, borderRadius: box.radius * width, overflow: box.radius > 0 ? "hidden" : undefined,
|
|
31
|
+
// The layer sits above the scenes; it must not be the thing a pointer-like overlay thinks it hits.
|
|
32
|
+
pointerEvents: "none",
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
{typeof children === "function" ? children({ ...box, widthPx, heightPx }) : children}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
};
|