reelkit-cli 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +7 -2
- package/skill/SKILL.md +22 -11
- package/skill/THIRD_PARTY.md +102 -0
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +32 -17
- package/skill/reference/captions.md +11 -5
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +140 -11
- package/skill/reference/launch-film.md +190 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/three-d.md +134 -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 +25 -8
- package/src/commands/assets.ts +334 -36
- package/src/commands/build.ts +172 -36
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- 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 +27 -5
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +67 -7
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/layers.ts +60 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +68 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +7 -2
- package/src/project/refmeasure.ts +1 -1
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/remotion/kit/Assemble3D.tsx +92 -0
- package/src/remotion/kit/BrowserFrame.tsx +83 -0
- package/src/remotion/kit/Camera.tsx +6 -4
- package/src/remotion/kit/Captions.tsx +33 -17
- package/src/remotion/kit/Card3D.tsx +211 -0
- package/src/remotion/kit/ChapterFrame.tsx +68 -0
- package/src/remotion/kit/CounterRoll.tsx +75 -0
- package/src/remotion/kit/GlassPanel.tsx +43 -0
- package/src/remotion/kit/Grounds.tsx +177 -0
- package/src/remotion/kit/Headline.tsx +97 -0
- package/src/remotion/kit/Hero3D.tsx +197 -0
- package/src/remotion/kit/HudOverlay.tsx +52 -0
- package/src/remotion/kit/ImageLayers.tsx +48 -0
- package/src/remotion/kit/Music.tsx +4 -4
- package/src/remotion/kit/NamedCursor.tsx +54 -0
- package/src/remotion/kit/Orbit3D.tsx +49 -0
- package/src/remotion/kit/Particles3D.tsx +74 -0
- package/src/remotion/kit/Place.tsx +12 -0
- package/src/remotion/kit/PromptBox.tsx +84 -0
- package/src/remotion/kit/Scene3D.tsx +70 -0
- package/src/remotion/kit/SceneFrame.tsx +88 -11
- package/src/remotion/kit/Sfx.tsx +12 -6
- package/src/remotion/kit/SoundCues.tsx +22 -0
- package/src/remotion/kit/TerminalLog.tsx +98 -0
- package/src/remotion/kit/Text3D.tsx +78 -0
- package/src/remotion/kit/TextOnImage.tsx +41 -0
- package/src/remotion/kit/Warp3D.tsx +59 -0
- package/src/remotion/kit/bg-math.ts +179 -0
- package/src/remotion/kit/caption-groups.ts +7 -3
- package/src/remotion/kit/caption-style.ts +45 -0
- package/src/remotion/kit/docs.ts +133 -11
- package/src/remotion/kit/image-layers-math.ts +115 -0
- package/src/remotion/kit/index.ts +43 -1
- package/src/remotion/kit/inter-bold-typeface.ts +3 -0
- package/src/remotion/kit/media.ts +5 -3
- package/src/remotion/kit/motion-math.ts +36 -2
- package/src/remotion/kit/music-math.ts +27 -10
- package/src/remotion/kit/quiet-three.ts +11 -0
- package/src/remotion/kit/sample-text.ts +55 -0
- package/src/remotion/kit/scene3d-context.ts +5 -0
- package/src/remotion/kit/seeded.ts +13 -0
- package/src/remotion/kit/sound-cues.ts +89 -0
- package/src/remotion/kit/sound-kinds.ts +122 -0
- package/src/remotion/kit/theme.ts +2 -0
- package/src/remotion/kit/three-fx-math.ts +192 -0
- package/src/remotion/kit/three-math.ts +145 -0
- package/src/remotion/kit/transition-math.ts +116 -0
- package/src/remotion/kit/ui-math.ts +145 -0
- package/src/remotion/kit/ui-theme.ts +25 -0
- package/src/remotion/kit/word-anchor.ts +107 -0
- 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/master.ts +31 -0
- package/src/render/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +3 -150
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +61 -1
- package/src/testing/fake-api.ts +11 -5
- package/src/testing/fixtures.ts +3 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React, { useContext } from "react";
|
|
2
|
+
import { Audio, Sequence } from "remotion";
|
|
3
|
+
import { gainFor, ManifestContext, UrlsContext } from "./media";
|
|
4
|
+
import { resolveCues, type SoundCue } from "./sound-cues";
|
|
5
|
+
|
|
6
|
+
// Plays many sound effects from one place: a list of cues, each a frame of the whole video and a name from `sounds`. Place it once, outside
|
|
7
|
+
// the scenes, when a video is scored with sound (a video with no voice has dozens of cues). Like Sfx it applies the manifest's levelling gain,
|
|
8
|
+
// so the same volume is equally loud for every file. A cue without a volume plays at 0.35.
|
|
9
|
+
export const SoundCues: React.FC<{ cues: SoundCue[]; sounds: Record<string, string>; volume?: number }> = ({ cues, sounds, volume = 1 }) => {
|
|
10
|
+
const urls = useContext(UrlsContext);
|
|
11
|
+
const gains = useContext(ManifestContext)?.soundGain;
|
|
12
|
+
const plan = React.useMemo(() => resolveCues({ cues, sounds, volume, urls, gainFor: (src) => gainFor(gains, urls, src) }), [cues, sounds, volume, urls, gains]);
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
{plan.map((c, i) => (
|
|
16
|
+
<Sequence key={`${c.from}-${i}`} from={c.from} layout="none">
|
|
17
|
+
<Audio src={c.src} volume={c.volume} />
|
|
18
|
+
</Sequence>
|
|
19
|
+
))}
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { Place } from "./Place";
|
|
4
|
+
import { font as loadFont, fonts, springs } from "./theme";
|
|
5
|
+
import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
|
|
6
|
+
import { clock, terminalFrames, type TerminalLine } from "./ui-math";
|
|
7
|
+
|
|
8
|
+
// A terminal or agent window: a title bar, lines that stream in (`cmd` a typed command, `out` plain output, `ok` a success, `warn` a warning, `edit` a file
|
|
9
|
+
// changed with `add` and `del` counts), an optional checklist whose items get struck through, and a status line with an elapsed counter that turns to
|
|
10
|
+
// "Done" at the end. `rate` is lines a second (default 3); the window shows the last `maxLines` (default 8) and the newest is at the bottom. Times are frames
|
|
11
|
+
// from the component's own mount; terminalFrames(props, fps) gives each line's frame and each tick's for sound. Text is monospace (jetbrainsMono) unless
|
|
12
|
+
// `font` is given. `width` is a fraction of the frame (0.86); `theme` "dark" (default), "light" or "glass".
|
|
13
|
+
export const TerminalLog: React.FC<{
|
|
14
|
+
lines: TerminalLine[];
|
|
15
|
+
rate?: number;
|
|
16
|
+
title?: string;
|
|
17
|
+
status?: string;
|
|
18
|
+
checklist?: string[];
|
|
19
|
+
checkEvery?: number;
|
|
20
|
+
maxLines?: number;
|
|
21
|
+
speed?: number;
|
|
22
|
+
hero?: string;
|
|
23
|
+
theme?: UiTheme;
|
|
24
|
+
font?: string;
|
|
25
|
+
width?: number;
|
|
26
|
+
delay?: number;
|
|
27
|
+
y?: number;
|
|
28
|
+
}> = ({ lines, rate = 3, title = "agent", status = "Working", checklist = [], checkEvery, maxLines = 8, speed = 8, hero = DEFAULT_HERO, theme = "dark", font, width: fraction = 0.86, delay = 6, y = 0.5 }) => {
|
|
29
|
+
const frame = useCurrentFrame();
|
|
30
|
+
const { fps, width: W } = useVideoConfig();
|
|
31
|
+
const c = uiColors(theme, hero);
|
|
32
|
+
const P = W * fraction;
|
|
33
|
+
const mono = font ?? loadFont("jetbrainsMono");
|
|
34
|
+
const ev = terminalFrames({ lines, rate, delay, checklist, checkEvery }, fps);
|
|
35
|
+
const shown = lines.map((_, i) => i).filter((i) => ev.lines[i]! <= frame);
|
|
36
|
+
const visible = shown.slice(-maxLines);
|
|
37
|
+
const fs = P * 0.034, row = fs * 1.62;
|
|
38
|
+
const enter = spring({ frame, fps, config: springs.smooth });
|
|
39
|
+
const done = frame >= ev.done;
|
|
40
|
+
const seconds = Math.max(0, ((done ? ev.done : frame) - delay) / fps * speed);
|
|
41
|
+
const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][Math.floor(frame / 2) % 10];
|
|
42
|
+
const colorOf = (k: TerminalLine["kind"]) => (k === "cmd" ? c.ink : k === "ok" ? c.ok : k === "warn" ? c.warn : k === "edit" ? c.ink : c.dim);
|
|
43
|
+
return (
|
|
44
|
+
<Place y={y}>
|
|
45
|
+
<div style={{ width: P, opacity: enter, transform: `translateY(${(1 - enter) * P * 0.04}px)`, fontFamily: mono }}>
|
|
46
|
+
<div style={{ background: c.panel, border: `${Math.max(2, P * 0.0025)}px solid ${c.stroke}`, borderRadius: P * 0.04, overflow: "hidden", boxShadow: `0 ${P * 0.03}px ${P * 0.08}px ${c.shadow}` }}>
|
|
47
|
+
<div style={{ display: "flex", alignItems: "center", gap: P * 0.018, padding: `${P * 0.032}px ${P * 0.04}px`, background: c.field, borderBottom: `1px solid ${c.stroke}` }}>
|
|
48
|
+
{["#ff5f57", "#febc2e", "#28c840"].map((d) => <div key={d} style={{ width: P * 0.03, height: P * 0.03, borderRadius: 999, background: d }} />)}
|
|
49
|
+
<div style={{ flex: 1, textAlign: "center", fontSize: fs * 0.9, color: c.dim, fontFamily: font ?? fonts.body, fontWeight: 600, marginRight: P * 0.1 }}>{title}</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div style={{ padding: `${P * 0.04}px ${P * 0.045}px`, height: row * maxLines + P * 0.08, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
|
52
|
+
{visible.map((i) => {
|
|
53
|
+
const l = lines[i]!, age = frame - ev.lines[i]!;
|
|
54
|
+
const o = interpolate(age, [0, 5], [0, 1], { extrapolateRight: "clamp" });
|
|
55
|
+
const mark = l.kind === "cmd" ? "$" : l.kind === "ok" ? "✓" : l.kind === "warn" ? "!" : l.kind === "edit" ? "✎" : " ";
|
|
56
|
+
return (
|
|
57
|
+
<div key={i} style={{ display: "flex", gap: fs * 0.7, height: row, alignItems: "center", fontSize: fs, color: colorOf(l.kind), opacity: o, transform: `translateY(${(1 - o) * fs * 0.4}px)`, whiteSpace: "nowrap" }}>
|
|
58
|
+
<span style={{ width: fs * 0.9, color: l.kind === "cmd" ? hero : colorOf(l.kind), fontWeight: 700, flex: "none" }}>{mark}</span>
|
|
59
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", flex: 1, fontWeight: l.kind === "cmd" ? 700 : 400 }}>{l.text}</span>
|
|
60
|
+
{l.kind === "edit" ? (
|
|
61
|
+
<span style={{ flex: "none", fontWeight: 700 }}>
|
|
62
|
+
{l.add !== undefined ? <span style={{ color: c.ok }}>+{l.add} </span> : null}
|
|
63
|
+
{l.del !== undefined ? <span style={{ color: c.bad }}>-{l.del}</span> : null}
|
|
64
|
+
</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
{checklist.length ? (
|
|
71
|
+
<div style={{ padding: `${P * 0.03}px ${P * 0.045}px`, borderTop: `1px solid ${c.stroke}`, display: "flex", flexDirection: "column", gap: P * 0.012 }}>
|
|
72
|
+
{checklist.map((item, i) => {
|
|
73
|
+
const at = ev.checks[i]!, k = interpolate(frame, [at, at + 6], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
74
|
+
return (
|
|
75
|
+
<div key={i} style={{ display: "flex", alignItems: "center", gap: fs * 0.7, fontSize: fs, color: k > 0.5 ? c.dim : c.ink }}>
|
|
76
|
+
<div style={{ width: fs, height: fs, borderRadius: fs * 0.25, border: `2px solid ${k > 0 ? hero : c.dim}`, background: k > 0.5 ? hero : "transparent", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
|
|
77
|
+
{k > 0.5 ? <svg width={fs * 0.7} height={fs * 0.7} viewBox="0 0 24 24" fill="none" stroke={c.heroInk} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg> : null}
|
|
78
|
+
</div>
|
|
79
|
+
<span style={{ position: "relative", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
80
|
+
{item}
|
|
81
|
+
<span style={{ position: "absolute", left: 0, top: "52%", height: Math.max(2, fs * 0.08), width: `${k * 100}%`, background: c.dim }} />
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
<div style={{ display: "flex", alignItems: "center", gap: fs * 0.6, padding: `${P * 0.03}px ${P * 0.045}px`, background: c.field, borderTop: `1px solid ${c.stroke}`, fontSize: fs * 0.92, color: done ? c.ok : hero, fontWeight: 700 }}>
|
|
89
|
+
<span style={{ width: fs, textAlign: "center" }}>{done ? "✓" : spin}</span>
|
|
90
|
+
<span>{done ? "Done" : `${status}...`}</span>
|
|
91
|
+
<span style={{ marginLeft: "auto", color: c.dim, fontWeight: 400 }}>{clock(seconds)}</span>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", boxShadow: `inset 0 0 0 0 ${rgba(hero, 0)}` }} />
|
|
96
|
+
</Place>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
|
|
4
|
+
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
|
|
5
|
+
import { interBoldTypeface } from "./inter-bold-typeface";
|
|
6
|
+
import { CameraKeysContext } from "./scene3d-context";
|
|
7
|
+
import { degToRad, entranceAt, fitTextSize, nearestVisible, type Entrance3D, type Vec3 } from "./three-math";
|
|
8
|
+
|
|
9
|
+
// The one bundled typeface, parsed once. It holds Latin letters, digits and common punctuation only.
|
|
10
|
+
let font: ReturnType<FontLoader["parse"]> | undefined;
|
|
11
|
+
export const typeface = () => (font ??= new FontLoader().parse(interBoldTypeface as never));
|
|
12
|
+
|
|
13
|
+
// The shape of Text3D's geometry for a given size, one place so that measuring and drawing cannot disagree.
|
|
14
|
+
const build = (text: string, size: number, extrude: number): TextGeometry => {
|
|
15
|
+
const g = new TextGeometry(text, {
|
|
16
|
+
font: typeface(), size, depth: extrude, curveSegments: 6,
|
|
17
|
+
bevelEnabled: true, bevelThickness: size * 0.02, bevelSize: size * 0.015, bevelSegments: 2,
|
|
18
|
+
});
|
|
19
|
+
g.computeBoundingBox();
|
|
20
|
+
return g;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// How wide and tall a text is at size 1, measured on the typeface's own glyphs (a line break starts a new line). The geometry scales with size, so
|
|
24
|
+
// this is the width at any size divided by that size.
|
|
25
|
+
const measured = new Map<string, { width: number; height: number }>();
|
|
26
|
+
export function measureText(text: string): { width: number; height: number } {
|
|
27
|
+
let m = measured.get(text);
|
|
28
|
+
if (!m) {
|
|
29
|
+
const g = build(text, 1, 0.25), b = g.boundingBox!;
|
|
30
|
+
m = { width: b.max.x - b.min.x, height: b.max.y - b.min.y };
|
|
31
|
+
g.dispose();
|
|
32
|
+
measured.set(text, m);
|
|
33
|
+
}
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Extruded text with depth, lit by the scene's lights. Place it inside a Scene3D. Latin text only: the bundled typeface has no Hebrew, Arabic or other
|
|
38
|
+
// scripts, so those stay 2D. Leave `size` out: the word is then sized from its own measured width so that it fills `fit` of the frame's width (default
|
|
39
|
+
// 0.8) at the closest the Scene3D's camera comes to it, which is the widest it ever has to be and whole at every moment; a line break (\n) makes a
|
|
40
|
+
// second line, and the height is held to `fit` of the frame's height too. A `size` you give wins (the height of a capital-ish line in scene units; at z 6
|
|
41
|
+
// the camera sees about 3.1 units across on 9:16 and 10 on 16:9, see Scene3D). `depth` is how far it is extruded (default 0.25 of the size), `rotation`
|
|
42
|
+
// is in degrees, and `enter` is how it arrives: "rise" (up from below), "turn" (a quarter turn in), "fly" (from far away toward the camera) or "none",
|
|
43
|
+
// starting `delay` frames into the scene. Face it at the camera when it must be read: keep its rotation near 0 and the camera in front of it, and hold
|
|
44
|
+
// it at least a second and a half.
|
|
45
|
+
export const Text3D: React.FC<{
|
|
46
|
+
text: string;
|
|
47
|
+
size?: number;
|
|
48
|
+
fit?: number;
|
|
49
|
+
depth?: number;
|
|
50
|
+
color?: string;
|
|
51
|
+
position?: Vec3;
|
|
52
|
+
rotation?: Vec3;
|
|
53
|
+
enter?: Entrance3D;
|
|
54
|
+
delay?: number;
|
|
55
|
+
}> = ({ text, size: given, fit = 0.8, depth, color = "#ffffff", position = [0, 0, 0], rotation = [0, 0, 0], enter = "none", delay = 0 }) => {
|
|
56
|
+
const frame = useCurrentFrame();
|
|
57
|
+
const { fps, width, height } = useVideoConfig();
|
|
58
|
+
const keys = React.useContext(CameraKeysContext);
|
|
59
|
+
const size = given ?? fitTextSize(measureText(text), nearestVisible(keys, width / height, position[2]), fit);
|
|
60
|
+
const extrude = depth ?? size * 0.25;
|
|
61
|
+
const geometry = React.useMemo(() => {
|
|
62
|
+
const g = build(text, size, extrude);
|
|
63
|
+
const b = g.boundingBox!;
|
|
64
|
+
// Centred on its own middle, so that position places the middle of the word.
|
|
65
|
+
g.translate(-(b.max.x + b.min.x) / 2, -(b.max.y + b.min.y) / 2, -extrude / 2);
|
|
66
|
+
return g;
|
|
67
|
+
}, [text, size, extrude]);
|
|
68
|
+
React.useEffect(() => () => geometry.dispose(), [geometry]);
|
|
69
|
+
const e = entranceAt(enter, frame, delay, fps, size);
|
|
70
|
+
if (e.opacity <= 0) return null;
|
|
71
|
+
return (
|
|
72
|
+
<group position={[position[0] + e.offset[0], position[1] + e.offset[1], position[2] + e.offset[2]]} rotation={[degToRad(rotation[0]) + e.rotation[0], degToRad(rotation[1]) + e.rotation[1], degToRad(rotation[2]) + e.rotation[2]]} scale={e.scale}>
|
|
73
|
+
<mesh geometry={geometry}>
|
|
74
|
+
<meshStandardMaterial color={color} roughness={0.35} metalness={0.1} transparent={e.opacity < 1} opacity={e.opacity} />
|
|
75
|
+
</mesh>
|
|
76
|
+
</group>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { AbsoluteFill, useVideoConfig } from "remotion";
|
|
4
|
+
import { ImageLayersContext, TextInkContext } from "./ImageLayers";
|
|
5
|
+
import { inkFor, placementOf, REGIONS, zoneRect, type ImageLayersRecord, type Rect, type Region } from "./image-layers-math";
|
|
6
|
+
|
|
7
|
+
// The simple, always-correct way to put words on a picture. It sets its children in the picture's calm zone (the manifest's textZone, from the ImageLayers it sits in or
|
|
8
|
+
// from `layers`; or `zone`, a region name or a rectangle in fractions of the frame), chooses dark or light ink from how light that zone is (a Headline inside takes it),
|
|
9
|
+
// and lays a soft local scrim just behind the words, stronger the busier the zone is, so they read on any picture. When the picture has a subject layer the words go
|
|
10
|
+
// BEHIND it if the subject covers 10 to 60 percent of the text block (the title behind the person), and in front of it when it would hide more than 60 percent;
|
|
11
|
+
// `behindSubject` forces behind or front (a force behind still yields to front when most of the words would be hidden). Put it inside an ImageLayers. `ink` and
|
|
12
|
+
// `scrim` (0 to 1) override the choices.
|
|
13
|
+
export const TextOnImage: React.FC<{
|
|
14
|
+
layers?: ImageLayersRecord;
|
|
15
|
+
zone?: Region | Rect;
|
|
16
|
+
behindSubject?: boolean;
|
|
17
|
+
ink?: string;
|
|
18
|
+
scrim?: number;
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
}> = ({ layers: given, zone, behindSubject, ink, scrim, children }) => {
|
|
21
|
+
const { width, height } = useVideoConfig();
|
|
22
|
+
const shared = React.useContext(ImageLayersContext);
|
|
23
|
+
const layers = given ?? shared?.layers;
|
|
24
|
+
const region: Region | undefined = typeof zone === "string" ? zone : zone ? undefined : layers?.textZone.region;
|
|
25
|
+
const rect: Rect = typeof zone === "object" ? zone : zoneRect(region ?? "bottom");
|
|
26
|
+
const picked = inkFor(layers?.textZone ?? { luminance: 0.2, busy: 0.5 });
|
|
27
|
+
const colour = ink ?? picked.ink, strength = scrim ?? picked.strength;
|
|
28
|
+
const place = placementOf(rect, layers?.subject ? layers.subjectBox : undefined, behindSubject);
|
|
29
|
+
const body = (
|
|
30
|
+
<>
|
|
31
|
+
<div style={{ position: "absolute", left: `${(rect.x - rect.w * 0.12) * 100}%`, top: `${(rect.y - rect.h * 0.35) * 100}%`, width: `${rect.w * 1.24 * 100}%`, height: `${rect.h * 1.7 * 100}%`, background: `radial-gradient(ellipse at 50% 50%, rgba(${picked.scrim},${strength}) 0%, rgba(${picked.scrim},${strength * 0.7}) 40%, rgba(${picked.scrim},0) 72%)`, pointerEvents: "none" }} />
|
|
32
|
+
<TextInkContext.Provider value={colour}>
|
|
33
|
+
<div style={{ position: "absolute", left: `${rect.x * 100}%`, top: `${rect.y * 100}%`, width: `${rect.w * 100}%`, height: `${rect.h * 100}%`, color: colour, display: "flex", alignItems: "center", justifyContent: "center" }}>{children}</div>
|
|
34
|
+
</TextInkContext.Provider>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
void width; void height; void REGIONS;
|
|
38
|
+
// In front of the subject: the words are drawn into ImageLayers's front layer instead of its middle one.
|
|
39
|
+
if (layers?.subject && !place.behind && shared?.frontRoot) return createPortal(<AbsoluteFill>{body}</AbsoluteFill>, shared.frontRoot);
|
|
40
|
+
return <AbsoluteFill>{body}</AbsoluteFill>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { AdditiveBlending, BufferAttribute, Color, DoubleSide, type BufferGeometry } from "three";
|
|
4
|
+
import { warpSpeed, warpStreaks, warpTravel, type SpeedKey } from "./three-fx-math";
|
|
5
|
+
import type { Vec3 } from "./three-math";
|
|
6
|
+
|
|
7
|
+
// Speed streaks rushing out of a vanishing point at the middle of the frame, additive and in two colours: a ground for a hyperspace beat, or a 12 to 20 frame burst between
|
|
8
|
+
// scenes. `speed` is a number or keys { frame, speed } joined smoothly (the streaks are longer and quicker the higher it is; 1 is a steady rush); or give `burst` { frame,
|
|
9
|
+
// frames, peak? } for a short rise and fall that is nothing outside it and fades the streaks in and out. `count` streaks (500), `colors` two colours, `seed`.
|
|
10
|
+
// Put it in a Scene3D with the default camera or any camera that looks down the z axis; it is drawn with no depth test, so it sits over anything else in the scene.
|
|
11
|
+
export const Warp3D: React.FC<{
|
|
12
|
+
speed?: number | SpeedKey[];
|
|
13
|
+
burst?: { frame: number; frames: number; peak?: number };
|
|
14
|
+
count?: number;
|
|
15
|
+
colors?: [string, string];
|
|
16
|
+
width?: number;
|
|
17
|
+
seed?: number;
|
|
18
|
+
position?: Vec3;
|
|
19
|
+
}> = ({ speed = 1, burst, count = 500, colors = ["#7C3AED", "#22D3EE"], width = 0.035, seed = 0, position = [0, 0, 0] }) => {
|
|
20
|
+
const frame = useCurrentFrame();
|
|
21
|
+
const { fps } = useVideoConfig();
|
|
22
|
+
const streaks = React.useMemo(() => warpStreaks(count, seed), [count, seed]);
|
|
23
|
+
const n = streaks.length;
|
|
24
|
+
const geom = React.useRef<BufferGeometry>(null);
|
|
25
|
+
const index = React.useMemo(() => { const a = new Uint32Array(n * 6); for (let i = 0; i < n; i++) a.set([i * 4, i * 4 + 1, i * 4 + 2, i * 4 + 1, i * 4 + 3, i * 4 + 2], i * 6); return a; }, [n]);
|
|
26
|
+
const A = React.useMemo(() => new Color(colors[0]), [colors]), B = React.useMemo(() => new Color(colors[1]), [colors]);
|
|
27
|
+
const v = warpSpeed(frame, speed, burst);
|
|
28
|
+
const travel = warpTravel(frame, speed, burst);
|
|
29
|
+
const { pos, col } = React.useMemo(() => {
|
|
30
|
+
const D = 35, zFar = -30, pos = new Float32Array(n * 12), col = new Float32Array(n * 12), c = new Color();
|
|
31
|
+
const fade = burst ? Math.min(1, v * 1.4) : 1;
|
|
32
|
+
streaks.forEach((s, i) => {
|
|
33
|
+
const z = zFar + ((((s.z * D + travel * 0.35) % D) + D) % D), len = (0.25 + Math.min(6, v) * 1.4) * (0.35 + 0.65 * s.mix), tail = z - len;
|
|
34
|
+
const x = s.x * 4, y = s.y * 4, r = Math.hypot(x, y) || 1, nx = -y / r, ny = x / r, w = width * (0.5 + s.mix);
|
|
35
|
+
const verts = [[x + nx * w, y + ny * w, z], [x - nx * w, y - ny * w, z], [x + nx * w, y + ny * w, tail], [x - nx * w, y - ny * w, tail]];
|
|
36
|
+
c.copy(A).lerp(B, s.mix);
|
|
37
|
+
const bright = Math.min(1, (z - zFar) / D + 0.15) * fade;
|
|
38
|
+
verts.forEach((p, k) => { const o = i * 12 + k * 3, head = k < 2 ? 1 : 0; pos[o] = p[0]!; pos[o + 1] = p[1]!; pos[o + 2] = p[2]!; col[o] = c.r * head * bright; col[o + 1] = c.g * head * bright; col[o + 2] = c.b * head * bright; });
|
|
39
|
+
});
|
|
40
|
+
return { pos, col };
|
|
41
|
+
}, [streaks, n, v, travel, burst, A, B, width]);
|
|
42
|
+
React.useLayoutEffect(() => {
|
|
43
|
+
const g = geom.current;
|
|
44
|
+
if (!g) return;
|
|
45
|
+
g.setAttribute("position", new BufferAttribute(pos, 3));
|
|
46
|
+
g.setAttribute("color", new BufferAttribute(col, 3));
|
|
47
|
+
g.setIndex(new BufferAttribute(index, 1));
|
|
48
|
+
g.computeBoundingSphere();
|
|
49
|
+
}, [pos, col, index]);
|
|
50
|
+
void fps;
|
|
51
|
+
return (
|
|
52
|
+
<group position={position}>
|
|
53
|
+
<mesh frustumCulled={false} renderOrder={10}>
|
|
54
|
+
<bufferGeometry ref={geom} />
|
|
55
|
+
<meshBasicMaterial vertexColors transparent blending={AdditiveBlending} depthWrite={false} depthTest={false} side={DoubleSide} toneMapped={false} />
|
|
56
|
+
</mesh>
|
|
57
|
+
</group>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The arithmetic behind the kit's grounds (BgVideo, BgImage, BgSequence and the animated grounds), with no React in it. Everything is a pure function
|
|
2
|
+
// of the frame, so any frame can be drawn on its own.
|
|
3
|
+
const clamp01 = (n: number) => (n > 0 ? (n < 1 ? n : 1) : 0);
|
|
4
|
+
const mixN = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
5
|
+
|
|
6
|
+
// --- A video ground that loops with a cross-fade over the join -------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CROSSFADE = 12;
|
|
9
|
+
export type VideoLayer = { index: number; start: number; opacity: number };
|
|
10
|
+
|
|
11
|
+
// Which plays of the clip are on screen at `frame`. A play is the clip from its first frame, started at `start` (a composition frame) and `clipFrames` long
|
|
12
|
+
// (its length at the playback rate). With `loop`, the next play starts `crossfade` frames before the last one ends and fades in over them, so the restart
|
|
13
|
+
// is never a jump; the earlier play stays at full opacity beneath until the new one has covered it. Without it the clip plays once and holds its last frame.
|
|
14
|
+
export function videoLayers(frame: number, o: { clipFrames: number; loop: boolean; crossfade?: number }): VideoLayer[] {
|
|
15
|
+
const clip = Math.max(1, Math.floor(o.clipFrames));
|
|
16
|
+
if (!o.loop) return [{ index: 0, start: 0, opacity: 1 }];
|
|
17
|
+
const fade = Math.max(0, Math.min(o.crossfade ?? DEFAULT_CROSSFADE, Math.floor(clip / 2)));
|
|
18
|
+
const period = Math.max(1, clip - fade);
|
|
19
|
+
const f = Math.max(0, frame);
|
|
20
|
+
const k = Math.floor(f / period);
|
|
21
|
+
const age = f - k * period;
|
|
22
|
+
const current: VideoLayer = { index: k, start: k * period, opacity: k > 0 && fade > 0 ? clamp01((age + 1) / (fade + 1)) : 1 };
|
|
23
|
+
if (k > 0 && age < fade) return [{ index: k - 1, start: (k - 1) * period, opacity: 1 }, current];
|
|
24
|
+
return [current];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- A still ground that always covers the frame ------------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type Drift = "none" | "left" | "right" | "up" | "down";
|
|
30
|
+
export type PlateView = { scale: number; tx: number; ty: number };
|
|
31
|
+
|
|
32
|
+
// How a still is placed over time. `progress` is how far through its time on screen it is (0 to 1). The scale grows by `zoom` over that time, and
|
|
33
|
+
// `drift` pans a few percent (`driftAmount` of the frame, each way). The base scale is chosen so that the pan and the blur never show an edge: the
|
|
34
|
+
// picture is wider than the frame by at least twice the largest shift. tx and ty are fractions of the frame's width and height.
|
|
35
|
+
export function plateView(progress: number, o: { zoom?: number; drift?: Drift; driftAmount?: number; blurPx?: number; width?: number } = {}): PlateView {
|
|
36
|
+
const p = clamp01(progress);
|
|
37
|
+
const amount = o.drift && o.drift !== "none" ? o.driftAmount ?? 0.04 : 0;
|
|
38
|
+
const blurMargin = o.blurPx && o.width ? (4 * o.blurPx) / o.width : 0;
|
|
39
|
+
const base = 1 + 2 * amount + blurMargin;
|
|
40
|
+
const scale = base * (1 + (o.zoom ?? 0.06) * p);
|
|
41
|
+
const along = mixN(amount, -amount, p);
|
|
42
|
+
const [dx, dy] = o.drift === "left" ? [along, 0] : o.drift === "right" ? [-along, 0] : o.drift === "up" ? [0, along] : o.drift === "down" ? [0, -along] : [0, 0];
|
|
43
|
+
return { scale, tx: dx, ty: dy };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- A ground that changes from one picture to the next ---------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export type SequenceItem = { src: string; from: number };
|
|
49
|
+
export type SequenceTransition = "fade" | "blur" | "wipe" | "zoom";
|
|
50
|
+
export type SequenceLayer = { index: number; opacity: number; blur: number; scale: number; wipe?: number; progress: number };
|
|
51
|
+
|
|
52
|
+
// The items in order, the first starting at frame 0 (it is visible from the first frame whatever its `from`). Two items out of order or on the same frame
|
|
53
|
+
// is a mistake that would put a picture on screen at the wrong time, so it is an explained error.
|
|
54
|
+
export function checkItems(items: readonly SequenceItem[]): void {
|
|
55
|
+
if (!items.length) throw new Error("BgSequence needs at least one item: give it items with a src and a from frame (bgPerScene(manifest.scenes, srcs) builds them).");
|
|
56
|
+
for (let i = 1; i < items.length; i++) {
|
|
57
|
+
if (items[i]!.from === items[i - 1]!.from) throw new Error(`BgSequence items ${i - 1} and ${i} start on the same frame (${items[i]!.from}). Give each its own frame.`);
|
|
58
|
+
if (items[i]!.from < items[i - 1]!.from) throw new Error(`BgSequence items are out of order: item ${i} starts at frame ${items[i]!.from}, before item ${i - 1} at ${items[i - 1]!.from}. List them by from.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The window in which the change into item `i` happens: centred on its `from`, or starting at it with align "start".
|
|
63
|
+
function windowOf(items: readonly SequenceItem[], i: number, frames: number, align: "center" | "start"): [number, number] {
|
|
64
|
+
const from = items[i]!.from;
|
|
65
|
+
// A window may not reach back past the item before, or the two changes would overlap.
|
|
66
|
+
const lo = align === "start" ? from : from - frames / 2;
|
|
67
|
+
const w0 = i > 0 ? Math.max(lo, items[i - 1]!.from) : lo;
|
|
68
|
+
return [w0, w0 + frames];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Which items are drawn at `frame`, bottom to top. Outside a change only one is drawn; during it two. The one underneath stays fully opaque, so there is
|
|
72
|
+
// never a gap, and the one on top comes in by the chosen transition. `progress` runs 0 to 1 over the time an item is on screen, for its own slow zoom,
|
|
73
|
+
// which carries on through the change so that nothing freezes or jumps.
|
|
74
|
+
export function sequenceLayers(items: readonly SequenceItem[], frame: number, o: { transition?: SequenceTransition; transitionFrames?: number; align?: "center" | "start" } = {}): SequenceLayer[] {
|
|
75
|
+
checkItems(items);
|
|
76
|
+
const frames = Math.max(1, Math.round(o.transitionFrames ?? 18)), align = o.align ?? "center", kind = o.transition ?? "fade";
|
|
77
|
+
const wins = items.map((_, i) => (i === 0 ? ([-Infinity, -Infinity] as [number, number]) : windowOf(items, i, frames, align)));
|
|
78
|
+
const progressOf = (i: number): number => {
|
|
79
|
+
const s = wins[i]![0] === -Infinity ? 0 : wins[i]![0];
|
|
80
|
+
const e = i + 1 < items.length ? wins[i + 1]![1] : Math.max(s + 1, s + 300);
|
|
81
|
+
return clamp01((frame - s) / Math.max(1, e - s));
|
|
82
|
+
};
|
|
83
|
+
// The item on top: the last whose window has begun.
|
|
84
|
+
let top = 0;
|
|
85
|
+
for (let i = 1; i < items.length; i++) if (frame >= wins[i]![0]) top = i;
|
|
86
|
+
const p = top === 0 ? 1 : clamp01((frame - wins[top]![0]) / frames);
|
|
87
|
+
if (top === 0 || p >= 1) return [{ index: top, opacity: 1, blur: 0, scale: 1, progress: progressOf(top) }];
|
|
88
|
+
const peak = Math.sin(Math.PI * p);
|
|
89
|
+
const under: SequenceLayer = { index: top - 1, opacity: 1, blur: kind === "blur" ? peak : 0, scale: kind === "zoom" ? 1 + 0.1 * p : 1, progress: progressOf(top - 1) };
|
|
90
|
+
const over: SequenceLayer = {
|
|
91
|
+
index: top, progress: progressOf(top), opacity: kind === "wipe" ? 1 : p, blur: kind === "blur" ? peak : 0, scale: kind === "zoom" ? 1.08 - 0.08 * p : 1,
|
|
92
|
+
...(kind === "wipe" ? { wipe: p } : {}),
|
|
93
|
+
};
|
|
94
|
+
return [under, over];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The items for a ground that changes exactly on scene starts, which are already on the beat. A scene whose src is missing keeps the picture before it.
|
|
98
|
+
export function bgPerScene(scenes: readonly { startFrame: number }[], srcs: readonly (string | undefined)[]): SequenceItem[] {
|
|
99
|
+
const items: SequenceItem[] = [];
|
|
100
|
+
scenes.forEach((s, i) => {
|
|
101
|
+
const src = srcs[i];
|
|
102
|
+
if (src && src !== items[items.length - 1]?.src) items.push({ src, from: items.length === 0 ? 0 : s.startFrame });
|
|
103
|
+
});
|
|
104
|
+
return items;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Grounds drawn in code ----------------------------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export type Blob = { x: number; y: number; r: number };
|
|
110
|
+
// Three large colour fields that drift and cross: centres as fractions of the frame, radii as fractions of its longer side. They travel about a third
|
|
111
|
+
// of the frame in two seconds, so the movement is plain within a short shot, and they cross, which is where the structure shows.
|
|
112
|
+
export function auroraBlobs(frame: number, fps: number, speed = 1): Blob[] {
|
|
113
|
+
const t = (frame / fps) * speed * 0.3 * 2 * Math.PI;
|
|
114
|
+
return [
|
|
115
|
+
{ x: 0.28 + 0.26 * Math.sin(t), y: 0.26 + 0.18 * Math.cos(t * 0.8), r: 0.5 },
|
|
116
|
+
{ x: 0.74 + 0.24 * Math.cos(t * 0.7 + 1), y: 0.58 + 0.2 * Math.sin(t * 0.9 + 2), r: 0.56 },
|
|
117
|
+
{ x: 0.5 + 0.34 * Math.sin(t * 0.5 + 4), y: 0.86 + 0.1 * Math.cos(t * 0.6), r: 0.46 },
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The one dial the animated grounds share: 0 is a faint ground and 1 a strong one, 0.6 by default, which reads on a phone. `strength` is what the
|
|
122
|
+
// grounds multiply their light by, from 0.2 to 1.
|
|
123
|
+
export const DEFAULT_INTENSITY = 0.6;
|
|
124
|
+
export const groundStrength = (intensity = DEFAULT_INTENSITY): number => 0.2 + 0.8 * clamp01(intensity);
|
|
125
|
+
|
|
126
|
+
// A colour rotated about the colour wheel by `degrees`, keeping its lightness and saturation: the neighbours of a hero colour for fields that differ in hue.
|
|
127
|
+
export function hueShift(hex: string, degrees: number): string {
|
|
128
|
+
const n = hex.replace("#", ""), f = n.length === 3 ? n.split("").map((c) => c + c).join("") : n;
|
|
129
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16) / 255) as [number, number, number];
|
|
130
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
|
|
131
|
+
let h = 0, sat = 0;
|
|
132
|
+
if (d > 0) {
|
|
133
|
+
sat = d / (1 - Math.abs(2 * l - 1));
|
|
134
|
+
h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
135
|
+
h *= 60;
|
|
136
|
+
}
|
|
137
|
+
h = (((h + degrees) % 360) + 360) % 360;
|
|
138
|
+
const c = (1 - Math.abs(2 * l - 1)) * sat, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = l - c / 2;
|
|
139
|
+
const [rr, gg, bb] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
|
140
|
+
const hexv = (v: number) => Math.round(Math.min(1, Math.max(0, v + m)) * 255).toString(16).padStart(2, "0");
|
|
141
|
+
return `#${hexv(rr)}${hexv(gg)}${hexv(bb)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A colour as rgba(), from a hex colour and an alpha from 0 to 1.
|
|
145
|
+
export function withAlpha(hex: string, alpha: number): string {
|
|
146
|
+
const n = hex.replace("#", ""), f = n.length === 3 ? n.split("").map((c) => c + c).join("") : n;
|
|
147
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16));
|
|
148
|
+
return `rgba(${r}, ${g}, ${b}, ${Math.round(clamp01(alpha) * 1000) / 1000})`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A perspective grid receding to a horizon. Horizontal lines get closer toward the horizon (squared spacing) and slide toward the viewer; `offset` is how
|
|
152
|
+
// far through one spacing they have slid (0 to 1, repeating), so the grid is seamless.
|
|
153
|
+
export function gridOffset(frame: number, fps: number, speed = 1): number {
|
|
154
|
+
const x = (frame / fps) * speed * 0.18;
|
|
155
|
+
return x - Math.floor(x);
|
|
156
|
+
}
|
|
157
|
+
export function gridRows(offset: number, count: number): number[] {
|
|
158
|
+
// Depth 0 is the horizon, 1 is the bottom edge. Rows are at depth ((i + offset) / count) ** 2.
|
|
159
|
+
return Array.from({ length: count }, (_, i) => ((i + offset) / count) ** 2);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Wide diagonal light beams sweeping across: the angle in degrees of each and how far it has slid sideways, as a fraction of the frame width, and its
|
|
163
|
+
// strength (0.4 to 1) as it brightens and dims. Four beams, a full sweep in about eight seconds.
|
|
164
|
+
export function beamStates(frame: number, fps: number, speed = 1): { angle: number; slide: number; strength: number }[] {
|
|
165
|
+
const t = (frame / fps) * speed * 0.16 * 2 * Math.PI;
|
|
166
|
+
return [0, 1, 2, 3].map((i) => ({ angle: 22 + i * 8 + 8 * Math.sin(t + i * 2), slide: -0.2 + i * 0.28 + 0.2 * Math.sin(t * 0.8 + i * 1.3), strength: 0.7 + 0.3 * Math.sin(t * 1.3 + i) }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A breathing vignette (0 to 1) and the seed of the film grain, which steps every 3 frames: the same frame always has the same grain.
|
|
170
|
+
export const breath = (frame: number, fps: number, speed = 1) => 0.5 + 0.5 * Math.sin((frame / fps) * speed * 0.35 * 2 * Math.PI);
|
|
171
|
+
export const grainSeed = (frame: number) => Math.floor(Math.max(0, frame) / 3) % 997;
|
|
172
|
+
|
|
173
|
+
// A colour between two hex colours, as a hex string.
|
|
174
|
+
export function mixHex(a: string, b: string, t: number): string {
|
|
175
|
+
const parse = (h: string) => { const n = h.replace("#", ""); const f = n.length === 3 ? n.split("").map((c) => c + c).join("") : n; return [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16)); };
|
|
176
|
+
const [ar, ag, ab] = parse(a) as [number, number, number], [br, bg, bb] = parse(b) as [number, number, number];
|
|
177
|
+
const hex = (n: number) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, "0");
|
|
178
|
+
return `#${hex(mixN(ar, br, t))}${hex(mixN(ag, bg, t))}${hex(mixN(ab, bb, t))}`;
|
|
179
|
+
}
|
|
@@ -3,6 +3,8 @@ import type { WordTiming } from "../../pipeline/schema";
|
|
|
3
3
|
export type CaptionGroup = "none" | "word" | "phrase";
|
|
4
4
|
|
|
5
5
|
export const MAX_GROUP_WORDS = 6;
|
|
6
|
+
// The size of a group when nothing says otherwise: a few words, never one.
|
|
7
|
+
export const DEFAULT_GROUP_WORDS = 3;
|
|
6
8
|
export const MAX_GROUP_CHARS = 32;
|
|
7
9
|
// A pause between two words at least this long ends a group.
|
|
8
10
|
export const PAUSE_SEC = 0.35;
|
|
@@ -23,11 +25,13 @@ const endsSentence = (w: string) => SENTENCE_END.test(clean(w));
|
|
|
23
25
|
const endsClause = (w: string) => CLAUSE_END.test(clean(w)) || LONE_DASH.test(clean(w));
|
|
24
26
|
const isJoining = (w: string) => JOINING.has(clean(w).toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""));
|
|
25
27
|
const chars = (ws: WordTiming[]) => ws.reduce((n, w, i) => n + clean(w.word).length + (i ? 1 : 0), 0);
|
|
26
|
-
const
|
|
28
|
+
const fitsIn = (max: number) => (ws: WordTiming[]) => ws.length <= max && chars(ws) <= MAX_GROUP_CHARS;
|
|
27
29
|
|
|
28
|
-
// Index ranges [start, end) into `words`, one per caption. "word": one word each. "phrase": grouped the way the words are spoken
|
|
29
|
-
|
|
30
|
+
// Index ranges [start, end) into `words`, one per caption. "word": one word each. "phrase": grouped the way the words are spoken, at most `maxWords`
|
|
31
|
+
// (default 6) words and about 32 characters in a group. A group never spans two sentences: it ends at ., ? or !.
|
|
32
|
+
export function captionGroups(words: WordTiming[], group: "word" | "phrase", opts: { maxWords?: number } = {}): [number, number][] {
|
|
30
33
|
if (group === "word") return words.map((_, i) => [i, i + 1]);
|
|
34
|
+
const fits = fitsIn(Math.max(1, Math.floor(opts.maxWords ?? MAX_GROUP_WORDS)));
|
|
31
35
|
const groups: [number, number][] = [];
|
|
32
36
|
let start = 0;
|
|
33
37
|
const gapAfter = (i: number) => (words[i + 1] ? words[i + 1]!.startSec - words[i]!.endSec : Infinity);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// What makes the default captions readable on any ground: a dark plate behind the words, white ink, and the spoken word on a chip of the film's
|
|
2
|
+
// accent (a karaoke fill, which has no chip, takes the accent lightened just enough to stay legible on the plate). Pure functions over hex colours.
|
|
3
|
+
|
|
4
|
+
// The plate behind a group of words: dark, rounded, about 58% opaque.
|
|
5
|
+
export const PLATE_RGB: [number, number, number] = [8, 10, 20];
|
|
6
|
+
export const PLATE_ALPHA = 0.58;
|
|
7
|
+
export const PLATE_COLOR = `rgba(${PLATE_RGB.join(",")},${PLATE_ALPHA})`;
|
|
8
|
+
// The contrast the spoken word must keep against the plate seen over the brightest ground (large bold type needs 3 to 1; the white ink has far more).
|
|
9
|
+
export const SPOKEN_CONTRAST = 3.2;
|
|
10
|
+
|
|
11
|
+
const parse = (hex: string): [number, number, number] => {
|
|
12
|
+
const h = hex.replace("#", "");
|
|
13
|
+
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h.slice(0, 6);
|
|
14
|
+
const n = Number.parseInt(full, 16);
|
|
15
|
+
return Number.isNaN(n) ? [255, 255, 255] : [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
16
|
+
};
|
|
17
|
+
const lin = (v: number) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
18
|
+
// WCAG relative luminance of an rgb colour, 0 to 1.
|
|
19
|
+
export const wcagLuminance = (rgb: readonly number[]): number => 0.2126 * lin(rgb[0]!) + 0.7152 * lin(rgb[1]!) + 0.0722 * lin(rgb[2]!);
|
|
20
|
+
// WCAG contrast ratio between two luminances.
|
|
21
|
+
export const contrastOf = (a: number, b: number): number => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
22
|
+
|
|
23
|
+
// The plate over a ground of colour `ground` (rgb).
|
|
24
|
+
export const plateOver = (ground: readonly number[]): [number, number, number] => PLATE_RGB.map((p, i) => p * PLATE_ALPHA + ground[i]! * (1 - PLATE_ALPHA)) as [number, number, number];
|
|
25
|
+
// The brightest the plate can look: over a white ground.
|
|
26
|
+
export const BRIGHTEST_PLATE_LUMINANCE = wcagLuminance(plateOver([255, 255, 255]));
|
|
27
|
+
|
|
28
|
+
const hex = (rgb: number[]) => `#${rgb.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
|
|
29
|
+
|
|
30
|
+
// The accent, mixed toward white until it keeps SPOKEN_CONTRAST against the brightest plate. A light accent is returned as it is.
|
|
31
|
+
export function legibleAccent(accent: string): string {
|
|
32
|
+
const c = parse(accent);
|
|
33
|
+
for (let k = 0; k <= 20; k++) {
|
|
34
|
+
const t = k / 20;
|
|
35
|
+
const mixed = c.map((v) => v + (255 - v) * t);
|
|
36
|
+
if (contrastOf(wcagLuminance(mixed), BRIGHTEST_PLATE_LUMINANCE) >= SPOKEN_CONTRAST) return hex(mixed);
|
|
37
|
+
}
|
|
38
|
+
return "#ffffff";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The ink that reads best on a chip of this accent colour: white, or a near-black when the accent is light.
|
|
42
|
+
export function inkOnAccent(accent: string): string {
|
|
43
|
+
const l = wcagLuminance(parse(accent));
|
|
44
|
+
return contrastOf(1, l) >= contrastOf(wcagLuminance([20, 20, 28]), l) ? "#ffffff" : "#14141c";
|
|
45
|
+
}
|