creator-editing-studio 1.3.8 → 1.4.1
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/history.json +1 -1
- package/package.json +1 -1
- package/template/CLAUDE.md +42 -8
- package/template/package.json +4 -1
- package/template/preview/lib.mjs +161 -19
- package/template/preview/player.html +71 -68
- package/template/preview/server.mjs +246 -23
- package/template/remotion.config.ts +5 -0
- package/template/scripts/approve.mjs +39 -0
- package/template/scripts/final-check.mjs +69 -0
- package/template/scripts/lib/plan-gate.mjs +99 -0
- package/template/scripts/plan-sheet.mjs +99 -0
- package/template/src/components/CommentBox.tsx +31 -0
- package/template/src/components/FilmBurn.tsx +48 -0
- package/template/src/components/FlashBloom.tsx +45 -0
- package/template/src/components/MergeToPill.tsx +56 -0
- package/template/src/components/NotificationDrop.tsx +49 -0
- package/template/src/components/PillCaptions.tsx +62 -0
- package/template/src/components/ReelRing.tsx +81 -0
- package/template/src/components/WhipScene.tsx +90 -0
- package/template/src/design/motion.ts +8 -0
- package/template/src/lib/brand.tsx +89 -0
- package/template/src/lib/layout.tsx +113 -0
- package/template/src/lib/recolor.ts +173 -0
- package/template/src/lib/tweaks.tsx +108 -6
- package/template/src/plan/entry.tsx +66 -0
- package/template/src/videos/whats-new/WhatsNewEdit.tsx +4 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand on the fly: the font and colours of an edit, switchable in the browser editor and applied everywhere
|
|
3
|
+
* at once.
|
|
4
|
+
*
|
|
5
|
+
* Edits and components colour themselves with CSS variables (with the house look as the fallback):
|
|
6
|
+
* var(--accent, your accent) the highlight colour var(--ink, #0f0f0d) the dark of cards and scenes
|
|
7
|
+
* var(--brand-font, …) every text (FONT.sans in src/design/tokens.ts already reads it)
|
|
8
|
+
* tints of the accent: color-mix(in srgb, var(--accent, your accent) 30%, transparent) — see tint()
|
|
9
|
+
* <LayoutProvider> sets the variables from layout.theme, so nothing in an edit has to know what was picked.
|
|
10
|
+
* A video with no theme looks exactly as built.
|
|
11
|
+
*/
|
|
12
|
+
import {loadFont as anton} from '@remotion/google-fonts/Anton';
|
|
13
|
+
import {loadFont as archivoBlack} from '@remotion/google-fonts/ArchivoBlack';
|
|
14
|
+
import {loadFont as bebasNeue} from '@remotion/google-fonts/BebasNeue';
|
|
15
|
+
import {loadFont as dmSerifDisplay} from '@remotion/google-fonts/DMSerifDisplay';
|
|
16
|
+
import {loadFont as instrumentSerif} from '@remotion/google-fonts/InstrumentSerif';
|
|
17
|
+
import {loadFont as inter} from '@remotion/google-fonts/Inter';
|
|
18
|
+
import {loadFont as manrope} from '@remotion/google-fonts/Manrope';
|
|
19
|
+
import {loadFont as montserrat} from '@remotion/google-fonts/Montserrat';
|
|
20
|
+
import {loadFont as oswald} from '@remotion/google-fonts/Oswald';
|
|
21
|
+
import {loadFont as outfit} from '@remotion/google-fonts/Outfit';
|
|
22
|
+
import {loadFont as playfairDisplay} from '@remotion/google-fonts/PlayfairDisplay';
|
|
23
|
+
import {loadFont as poppins} from '@remotion/google-fonts/Poppins';
|
|
24
|
+
import {loadFont as sora} from '@remotion/google-fonts/Sora';
|
|
25
|
+
import {loadFont as spaceGrotesk} from '@remotion/google-fonts/SpaceGrotesk';
|
|
26
|
+
import {loadFont as syne} from '@remotion/google-fonts/Syne';
|
|
27
|
+
|
|
28
|
+
export type BrandTheme = {font?: string; accent?: string; ink?: string};
|
|
29
|
+
|
|
30
|
+
export const HOUSE = {accent: 'your accent', ink: '#0f0f0d', font: 'your display typeface'} as const;
|
|
31
|
+
|
|
32
|
+
/** The fonts on offer: name → how to load it. 'your display typeface' is the house font (loaded by tokens.ts). */
|
|
33
|
+
type Loader = (style?: 'normal', opts?: {weights?: string[]; subsets?: string[]}) => {fontFamily: string};
|
|
34
|
+
const GOOGLE: Record<string, {load: Loader; weights: string[]; kind: string}> = {
|
|
35
|
+
Inter: {load: inter as Loader, weights: ['400', '600', '700', '800'], kind: 'Clean sans'},
|
|
36
|
+
Manrope: {load: manrope as Loader, weights: ['400', '600', '700', '800'], kind: 'Clean sans'},
|
|
37
|
+
Poppins: {load: poppins as Loader, weights: ['400', '600', '700', '800'], kind: 'Friendly sans'},
|
|
38
|
+
Montserrat: {load: montserrat as Loader, weights: ['400', '600', '700', '800'], kind: 'Bold sans'},
|
|
39
|
+
Outfit: {load: outfit as Loader, weights: ['400', '600', '700', '800'], kind: 'Modern sans'},
|
|
40
|
+
Sora: {load: sora as Loader, weights: ['400', '600', '700', '800'], kind: 'Tech sans'},
|
|
41
|
+
'Space Grotesk': {load: spaceGrotesk as Loader, weights: ['400', '600', '700'], kind: 'Tech sans'},
|
|
42
|
+
Syne: {load: syne as Loader, weights: ['400', '600', '700', '800'], kind: 'Editorial sans'},
|
|
43
|
+
'Archivo Black': {load: archivoBlack as Loader, weights: ['400'], kind: 'Heavy'},
|
|
44
|
+
Anton: {load: anton as Loader, weights: ['400'], kind: 'Tall & loud'},
|
|
45
|
+
'Bebas Neue': {load: bebasNeue as Loader, weights: ['400'], kind: 'Tall & loud'},
|
|
46
|
+
Oswald: {load: oswald as Loader, weights: ['400', '600', '700'], kind: 'Tall & loud'},
|
|
47
|
+
'Playfair Display': {load: playfairDisplay as Loader, weights: ['400', '600', '700', '800'], kind: 'Serif'},
|
|
48
|
+
'DM Serif Display': {load: dmSerifDisplay as Loader, weights: ['400'], kind: 'Serif'},
|
|
49
|
+
'Instrument Serif': {load: instrumentSerif as Loader, weights: ['400'], kind: 'Serif'},
|
|
50
|
+
};
|
|
51
|
+
export const BRAND_FONTS: {name: string; kind: string}[] = [{name: 'your display typeface', kind: 'House'}, ...Object.entries(GOOGLE).map(([name, g]) => ({name, kind: g.kind}))];
|
|
52
|
+
|
|
53
|
+
/** Colour sets that work on talking heads — one tap, or pick your own. */
|
|
54
|
+
export const PALETTES: {name: string; accent: string; ink: string}[] = [
|
|
55
|
+
{name: 'House accent', accent: 'your accent', ink: '#0f0f0d'},
|
|
56
|
+
{name: 'Signal orange', accent: '#ff7a1a', ink: '#120c08'},
|
|
57
|
+
{name: 'Electric blue', accent: '#4d8dff', ink: '#0a0e18'},
|
|
58
|
+
{name: 'Hot pink', accent: '#ff4fa3', ink: '#140a10'},
|
|
59
|
+
{name: 'Gold', accent: '#f2c14e', ink: '#12100a'},
|
|
60
|
+
{name: 'Mint', accent: '#5ff2c1', ink: '#08120f'},
|
|
61
|
+
{name: 'Red', accent: '#ff3b3b', ink: '#120808'},
|
|
62
|
+
{name: 'Clean white', accent: '#ffffff', ink: '#111111'},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const loaded = new Map<string, string>();
|
|
66
|
+
/** The CSS family for a font name — loading it the first time it's asked for. */
|
|
67
|
+
export function familyFor(name?: string): string | null {
|
|
68
|
+
if (!name || name === HOUSE.font) return null;
|
|
69
|
+
const g = GOOGLE[name];
|
|
70
|
+
if (!g) return null;
|
|
71
|
+
if (!loaded.has(name)) loaded.set(name, g.load('normal', {weights: g.weights, subsets: ['latin']}).fontFamily);
|
|
72
|
+
return loaded.get(name)!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The CSS variables for a theme — put them on the edit's outermost element. */
|
|
76
|
+
export function themeVars(t?: BrandTheme): React.CSSProperties {
|
|
77
|
+
if (!t) return {};
|
|
78
|
+
const vars: Record<string, string> = {};
|
|
79
|
+
if (t.accent) vars['--accent'] = t.accent;
|
|
80
|
+
if (t.ink) vars['--ink'] = t.ink;
|
|
81
|
+
const family = familyFor(t.font);
|
|
82
|
+
if (family) vars['--brand-font'] = `"${family}", -apple-system, Arial, sans-serif`;
|
|
83
|
+
return vars as React.CSSProperties;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The accent at some strength, as a CSS colour: tint(0.3) → 30% accent over transparent. */
|
|
87
|
+
export const tint = (amount: number, base = 'var(--accent, your accent)') => `color-mix(in srgb, ${base} ${Math.round(amount * 1000) / 10}%, transparent)`;
|
|
88
|
+
export const ACCENT = 'var(--accent, your accent)';
|
|
89
|
+
export const INK = 'var(--ink, #0f0f0d)';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser editor's layer: things on screen the person can grab, and effects they can switch.
|
|
3
|
+
*
|
|
4
|
+
* An edit names its movable pieces and its effect slots; the person drags, resizes, retimes and switches in
|
|
5
|
+
* the browser editor (editor/), which saves the result to the video's layout.json; the render reads the same
|
|
6
|
+
* file. No layout = the edit exactly as built.
|
|
7
|
+
*
|
|
8
|
+
* <LayoutProvider layout={layout}>
|
|
9
|
+
* <Movable id="hook-words" label="Hook words"><HookWords /></Movable>
|
|
10
|
+
* {useEffectChoice('opener', 'FlashBloom') === 'FlashBloom' ? <FlashBloom …/> : null}
|
|
11
|
+
* </LayoutProvider>
|
|
12
|
+
*
|
|
13
|
+
* The edit also exports EDITOR = {elements, slots} so the editor can list them (see a preview.tsx that exports it).
|
|
14
|
+
* Rule from <Tweaked>: never put a full-screen card that hides a join inside a Movable — move what's ON it.
|
|
15
|
+
*/
|
|
16
|
+
import React, {createContext, useContext} from 'react';
|
|
17
|
+
import {AbsoluteFill, Img, OffthreadVideo, Sequence, interpolate, staticFile, useCurrentFrame} from 'remotion';
|
|
18
|
+
import {themeVars, type BrandTheme} from './brand';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One piece's adjustments: x/y in px of the 1080×1920 frame, scale 0.3–3, shift in frames (+ later), hide,
|
|
22
|
+
* and start/end — composition frames outside which it isn't shown (trimmed on the editor's timeline).
|
|
23
|
+
*/
|
|
24
|
+
export type Placement = {x?: number; y?: number; scale?: number; shift?: number; hide?: boolean; start?: number; end?: number};
|
|
25
|
+
/** A clip or picture the person dropped on the editor's B-roll row. src is a staticFile path. */
|
|
26
|
+
export type Broll = {id: string; src: string; kind: 'video' | 'image'; from: number; to: number; offset?: number; mode?: 'full' | 'card'};
|
|
27
|
+
export type Layout = {elements?: Record<string, Placement>; effects?: Record<string, string>; broll?: Broll[]; theme?: BrandTheme};
|
|
28
|
+
|
|
29
|
+
/** What the editor lists: the pieces it can move and the effect slots it can switch. */
|
|
30
|
+
export type EditorSpec = {
|
|
31
|
+
elements: {id: string; label: string; from: number; to: number}[];
|
|
32
|
+
slots: {id: string; label: string; at: number; options: string[]; default: string}[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const Ctx = createContext<Layout>({});
|
|
36
|
+
|
|
37
|
+
/** Also applies the picked font and colours (layout.theme) to everything inside — see src/lib/brand.tsx. */
|
|
38
|
+
export const LayoutProvider: React.FC<{layout?: Layout; children: React.ReactNode}> = ({layout = {}, children}) => (
|
|
39
|
+
<Ctx.Provider value={layout}>{layout.theme ? <AbsoluteFill style={themeVars(layout.theme)}>{children}</AbsoluteFill> : children}</Ctx.Provider>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The effect chosen for a slot, or its default. */
|
|
43
|
+
export const useEffectChoice = (slot: string, fallback: string) => useContext(Ctx).effects?.[slot] ?? fallback;
|
|
44
|
+
|
|
45
|
+
/** The placement of one piece (for pieces that must apply it themselves, e.g. a split layer). */
|
|
46
|
+
export const usePlacement = (id: string): Placement => useContext(Ctx).elements?.[id] ?? {};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A piece the person can grab. Moves, scales (around the middle of the frame's upper half, where text sits)
|
|
50
|
+
* and retimes what's inside. data-movable lets the editor find it on screen.
|
|
51
|
+
*/
|
|
52
|
+
export const Movable: React.FC<{id: string; label?: string; origin?: string; children: React.ReactNode}> = ({id, origin = '50% 40%', children}) => {
|
|
53
|
+
const p = usePlacement(id);
|
|
54
|
+
const f = useCurrentFrame();
|
|
55
|
+
if (p.hide) return null;
|
|
56
|
+
if (p.start !== undefined && f < p.start) return null;
|
|
57
|
+
if (p.end !== undefined && f >= p.end) return null;
|
|
58
|
+
// a trimmed edge fades over 4 frames instead of popping
|
|
59
|
+
const fade = Math.min(p.start !== undefined ? interpolate(f, [p.start, p.start + 4], [0, 1], {extrapolateRight: 'clamp'}) : 1, p.end !== undefined ? interpolate(f, [p.end - 4, p.end], [1, 0], {extrapolateLeft: 'clamp'}) : 1);
|
|
60
|
+
const moved = p.x || p.y || (p.scale && p.scale !== 1);
|
|
61
|
+
const body = (
|
|
62
|
+
<AbsoluteFill data-movable={id} style={{pointerEvents: 'none', opacity: fade < 1 ? fade : undefined, transform: moved ? `translate(${p.x ?? 0}px, ${p.y ?? 0}px) scale(${p.scale ?? 1})` : undefined, transformOrigin: origin}}>
|
|
63
|
+
{children}
|
|
64
|
+
</AbsoluteFill>
|
|
65
|
+
);
|
|
66
|
+
return p.shift ? (
|
|
67
|
+
<Sequence from={p.shift} layout="none">
|
|
68
|
+
{body}
|
|
69
|
+
</Sequence>
|
|
70
|
+
) : (
|
|
71
|
+
body
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The B-roll the person dropped on the editor's timeline: full screen over the talking head, or a card
|
|
77
|
+
* (draggable/resizable like any piece, id "broll:<id>"). Muted — the voice carries on underneath.
|
|
78
|
+
* Put it above the footage and below the captions: <BrollLayer />
|
|
79
|
+
*/
|
|
80
|
+
export const BrollLayer: React.FC = () => {
|
|
81
|
+
const list = useContext(Ctx).broll ?? [];
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{list.map((b) => (
|
|
85
|
+
<Sequence key={b.id} from={b.from} durationInFrames={Math.max(1, b.to - b.from)} premountFor={45}>
|
|
86
|
+
<Movable id={`broll:${b.id}`} origin="50% 50%">
|
|
87
|
+
<BrollClip b={b} />
|
|
88
|
+
</Movable>
|
|
89
|
+
</Sequence>
|
|
90
|
+
))}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const BrollClip: React.FC<{b: Broll}> = ({b}) => {
|
|
96
|
+
const f = useCurrentFrame(); // local to the clip
|
|
97
|
+
const len = b.to - b.from;
|
|
98
|
+
const fade = Math.min(interpolate(f, [0, 5], [0, 1], {extrapolateRight: 'clamp'}), interpolate(f, [len - 5, len], [1, 0], {extrapolateLeft: 'clamp'}));
|
|
99
|
+
const media =
|
|
100
|
+
b.kind === 'video' ? (
|
|
101
|
+
<OffthreadVideo src={staticFile(b.src)} startFrom={b.offset ?? 0} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
|
|
102
|
+
) : (
|
|
103
|
+
<Img src={staticFile(b.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} />
|
|
104
|
+
);
|
|
105
|
+
if (b.mode === 'card')
|
|
106
|
+
return (
|
|
107
|
+
<AbsoluteFill style={{alignItems: 'center', justifyContent: 'center', opacity: fade}}>
|
|
108
|
+
<div style={{width: 820, height: 1000, borderRadius: 40, overflow: 'hidden', boxShadow: '0 40px 90px rgba(0,0,0,0.55)', border: '3px solid rgba(255,255,255,0.85)', transform: `scale(${0.94 + 0.06 * fade})`}}>{media}</div>
|
|
109
|
+
</AbsoluteFill>
|
|
110
|
+
);
|
|
111
|
+
// full screen: a slight push so stills don't sit dead
|
|
112
|
+
return <AbsoluteFill style={{opacity: fade, transform: `scale(${1.04 + 0.04 * (f / Math.max(1, len))})`}}>{media}</AbsoluteFill>;
|
|
113
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recolouring from the live preview (2026-11-05, his ask: "the option to change colors directly from the preview").
|
|
3
|
+
*
|
|
4
|
+
* A swap is {from: to}, e.g. {'#a88538': '#e2382f'}: gold → red. It moves the whole SHADE FAMILY of `from`, not one
|
|
5
|
+
* exact value — a gold medallion is five golds (highlight, mid, edge, shadow, glow) and they all turn red with their
|
|
6
|
+
* shading kept: hue moves by the same amount, saturation scales, lightness shifts. `from` itself lands exactly on `to`.
|
|
7
|
+
*
|
|
8
|
+
* It works on what the edit drew: inline styles (color, backgrounds, gradients, borders, shadows, filters) and SVG
|
|
9
|
+
* fill / stroke / stop-color, in hex or rgb(a). Footage, cut-outs and images are untouched. The original values are
|
|
10
|
+
* remembered per element, so running it every frame never drifts (gold → darker gold would otherwise darken again
|
|
11
|
+
* each frame).
|
|
12
|
+
*/
|
|
13
|
+
export type Swap = Record<string, string>;
|
|
14
|
+
type RGB = [number, number, number];
|
|
15
|
+
type HSL = [number, number, number];
|
|
16
|
+
|
|
17
|
+
const TOKEN = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b|rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*[\d.]+%?\s*)?\)/g;
|
|
18
|
+
|
|
19
|
+
export const hex = ([r, g, b]: RGB) => '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
|
|
20
|
+
|
|
21
|
+
/** A colour token → [rgb, alpha], or null. */
|
|
22
|
+
export function parse(tok: string): [RGB, number] | null {
|
|
23
|
+
if (tok.startsWith('#')) {
|
|
24
|
+
let h = tok.slice(1);
|
|
25
|
+
if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
|
|
26
|
+
const n = (i: number) => parseInt(h.slice(i, i + 2), 16);
|
|
27
|
+
return [[n(0), n(2), n(4)], h.length === 8 ? n(6) / 255 : 1];
|
|
28
|
+
}
|
|
29
|
+
const v = tok.replace(/rgba?\(|\)|\s/g, '').split(',');
|
|
30
|
+
if (v.length < 3) return null;
|
|
31
|
+
const a = v[3] === undefined ? 1 : v[3].endsWith('%') ? parseFloat(v[3]) / 100 : parseFloat(v[3]);
|
|
32
|
+
return [[+v[0], +v[1], +v[2]], a];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toHsl([r, g, b]: RGB): HSL {
|
|
36
|
+
r /= 255;
|
|
37
|
+
g /= 255;
|
|
38
|
+
b /= 255;
|
|
39
|
+
const max = Math.max(r, g, b);
|
|
40
|
+
const min = Math.min(r, g, b);
|
|
41
|
+
const l = (max + min) / 2;
|
|
42
|
+
if (max === min) return [0, 0, l];
|
|
43
|
+
const d = max - min;
|
|
44
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
45
|
+
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
46
|
+
return [h * 60, s, l];
|
|
47
|
+
}
|
|
48
|
+
function toRgb([h, s, l]: HSL): RGB {
|
|
49
|
+
h = (((h % 360) + 360) % 360) / 360;
|
|
50
|
+
if (s === 0) return [l * 255, l * 255, l * 255];
|
|
51
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
52
|
+
const p = 2 * l - q;
|
|
53
|
+
const f = (t: number) => {
|
|
54
|
+
t = (t + 1) % 1;
|
|
55
|
+
return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p;
|
|
56
|
+
};
|
|
57
|
+
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
|
|
58
|
+
}
|
|
59
|
+
const clamp = (x: number) => Math.max(0, Math.min(1, x));
|
|
60
|
+
const hueGap = (a: number, b: number) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
|
|
61
|
+
/** Greys, whites and near-blacks have no reliable hue: they only match greys of a similar lightness. */
|
|
62
|
+
const GREY = 0.1;
|
|
63
|
+
|
|
64
|
+
/** Does `c` belong to the shade family of `from`? */
|
|
65
|
+
export function inFamily(c: RGB, from: RGB): boolean {
|
|
66
|
+
const [h, s, l] = toHsl(c);
|
|
67
|
+
const [fh, fs, fl] = toHsl(from);
|
|
68
|
+
if (fs < GREY || s < GREY) return fs < GREY && s < GREY && Math.abs(l - fl) < 0.12;
|
|
69
|
+
return hueGap(h, fh) <= 24;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `c` moved the way `from` moves to `to`. */
|
|
73
|
+
export function shift(c: RGB, from: RGB, to: RGB): RGB {
|
|
74
|
+
const [h, s, l] = toHsl(c);
|
|
75
|
+
const [fh, fs, fl] = toHsl(from);
|
|
76
|
+
const [th, ts, tl] = toHsl(to);
|
|
77
|
+
const s2 = fs < GREY ? ts : clamp(s * (fs ? ts / fs : 1));
|
|
78
|
+
return toRgb([h + (th - fh), s2, clamp(l + (tl - fl))]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rules = (swap: Swap) => Object.entries(swap).map(([a, b]) => [parse(a)?.[0], parse(b)?.[0]] as [RGB | undefined, RGB | undefined]).filter((r): r is [RGB, RGB] => !!r[0] && !!r[1]);
|
|
82
|
+
|
|
83
|
+
/** Every colour in a style or attribute value, swapped. Alpha is kept. */
|
|
84
|
+
export function swapIn(value: string, swap: Swap): string {
|
|
85
|
+
const rs = rules(swap);
|
|
86
|
+
if (!rs.length) return value;
|
|
87
|
+
return value.replace(TOKEN, (tok) => {
|
|
88
|
+
const p = parse(tok);
|
|
89
|
+
if (!p) return tok;
|
|
90
|
+
const r = rs.find(([from]) => inFamily(p[0], from));
|
|
91
|
+
if (!r) return tok;
|
|
92
|
+
const c = shift(p[0], r[0], r[1]);
|
|
93
|
+
return p[1] < 1 ? `rgba(${c.map(Math.round).join(',')},${+p[1].toFixed(3)})` : hex(c);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ATTRS = ['fill', 'stroke', 'stop-color', 'color', 'flood-color'];
|
|
98
|
+
/** el → attribute or style property → [what the edit drew, what we wrote]. React sets style properties one by one,
|
|
99
|
+
* so they are remembered one by one: a property we wrote is read back as its original. */
|
|
100
|
+
const memo = new WeakMap<Element, Map<string, [string, string]>>();
|
|
101
|
+
const SKIP = new Set(['VIDEO', 'IMG', 'CANVAS', 'IFRAME']);
|
|
102
|
+
const hasColor = (v: string) => /#|rgb/.test(v);
|
|
103
|
+
|
|
104
|
+
/** [key, value the edit drew] for every coloured attribute and style property of an element. */
|
|
105
|
+
function drawn(el: Element): [string, string][] {
|
|
106
|
+
const m = memo.get(el);
|
|
107
|
+
const out: [string, string][] = [];
|
|
108
|
+
const back = (k: string, now: string) => {
|
|
109
|
+
const p = m?.get(k);
|
|
110
|
+
return p && now === p[1] ? p[0] : now;
|
|
111
|
+
};
|
|
112
|
+
for (const a of ATTRS) {
|
|
113
|
+
const v = el.getAttribute(a);
|
|
114
|
+
if (v && hasColor(v)) out.push([a, back(a, v)]);
|
|
115
|
+
}
|
|
116
|
+
const st = (el as HTMLElement).style;
|
|
117
|
+
if (st) for (let i = 0; i < st.length; i++) {
|
|
118
|
+
const p = st[i];
|
|
119
|
+
const v = st.getPropertyValue(p);
|
|
120
|
+
if (v && hasColor(v)) out.push(['style:' + p, back('style:' + p, v)]);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Apply a swap to an element and everything inside it. Safe to call every frame. Skips footage and images. */
|
|
126
|
+
export function recolorTree(root: Element, swap: Swap, skipParts = false) {
|
|
127
|
+
for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
|
|
128
|
+
if (SKIP.has(el.tagName)) continue;
|
|
129
|
+
// the whole-video pass leaves graphics that run their own (they include the whole video's swaps)
|
|
130
|
+
if (skipParts && el !== root && el.closest('[data-lp-recolor]')) continue;
|
|
131
|
+
for (const [k, base] of drawn(el)) {
|
|
132
|
+
const out = swapIn(base, swap);
|
|
133
|
+
let m = memo.get(el);
|
|
134
|
+
if (out === base && !m?.has(k)) continue;
|
|
135
|
+
if (!m) memo.set(el, (m = new Map()));
|
|
136
|
+
m.set(k, [base, out]);
|
|
137
|
+
if (k.startsWith('style:')) {
|
|
138
|
+
const st = (el as HTMLElement).style;
|
|
139
|
+
const p = k.slice(6);
|
|
140
|
+
if (st.getPropertyValue(p) !== out) st.setProperty(p, out, st.getPropertyPriority(p));
|
|
141
|
+
m.set(k, [base, st.getPropertyValue(p)]); // the browser may normalise what we wrote
|
|
142
|
+
} else if (el.getAttribute(k) !== out) el.setAttribute(k, out);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The distinct colours drawn inside an element (original values), most used first — the preview's swatches. */
|
|
148
|
+
export function colorsIn(root: Element, max = 8): string[] {
|
|
149
|
+
const count = new Map<string, number>();
|
|
150
|
+
for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
|
|
151
|
+
if (SKIP.has(el.tagName)) continue;
|
|
152
|
+
for (const [, v] of drawn(el)) {
|
|
153
|
+
for (const tok of v.match(TOKEN) ?? []) {
|
|
154
|
+
const p = parse(tok);
|
|
155
|
+
if (!p || p[1] < 0.2) continue;
|
|
156
|
+
const k = hex(p[0]);
|
|
157
|
+
count.set(k, (count.get(k) ?? 0) + 1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// one swatch per shade family: the most used member stands for it
|
|
162
|
+
const out: string[] = [];
|
|
163
|
+
for (const [k] of [...count].sort((a, b) => b[1] - a[1])) {
|
|
164
|
+
const c = parse(k)![0];
|
|
165
|
+
if (!out.some((o) => inFamily(c, parse(o)![0]))) out.push(k);
|
|
166
|
+
if (out.length >= max) break;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The live preview's player is a separate bundle: it lists swatches through the edit's own copy (which remembers the
|
|
172
|
+
// originals), so a swapped graphic still shows the colours it was built with.
|
|
173
|
+
if (typeof window !== 'undefined') (window as unknown as {__lpRecolor?: unknown}).__lpRecolor = {colorsIn};
|
|
@@ -13,26 +13,52 @@
|
|
|
13
13
|
* - Layers locked to the footage (a subject cutout) use <Tweaked move={false}> and read the tweak with
|
|
14
14
|
* useHereTweak() to move only their graphics.
|
|
15
15
|
*/
|
|
16
|
-
import React, {createContext, useContext} from 'react';
|
|
16
|
+
import React, {createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
|
17
|
+
import {createPortal} from 'react-dom';
|
|
17
18
|
import {AbsoluteFill, Sequence, useCurrentFrame} from 'remotion';
|
|
18
19
|
import {followTransform, type TrackFile} from './track';
|
|
20
|
+
import {recolorTree, type Swap} from './recolor';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* One beat's adjustments. All optional.
|
|
22
|
-
* scale size of text and graphics, 0.7–1.4 dy up (−) / down (+), px, ±300
|
|
24
|
+
* scale size of text and graphics, 0.7–1.4 dy up (−) / down (+), px, ±300 (dragged: further)
|
|
25
|
+
* dx left (−) / right (+), px — set by dragging on the video
|
|
23
26
|
* shift frames later (+) / earlier (−), ±12 gain that beat's sound effects, dB, −12…+6
|
|
24
27
|
* mute that beat's sound effects off icon swap a 3D icon: {cart: 'shop'}
|
|
25
28
|
* follow the beat's graphics move, scale and partly tilt with the speaker's head (needs a head track)
|
|
26
29
|
*/
|
|
27
|
-
export type Tweak = {
|
|
28
|
-
|
|
30
|
+
export type Tweak = {
|
|
31
|
+
scale?: number; dx?: number; dy?: number; shift?: number; gain?: number; mute?: boolean; icon?: Record<string, string>; follow?: boolean;
|
|
32
|
+
/** a <Part>'s text colour, its layer against the speaker, or hidden */
|
|
33
|
+
color?: string; layer?: 'front' | 'behind'; hide?: boolean;
|
|
34
|
+
/** colour swaps, {from: to}: each moves the whole shade family of `from` (src/lib/recolor.ts). Under the key
|
|
35
|
+
* ALL they apply to the whole video; on a <Part> they apply to that graphic first. */
|
|
36
|
+
recolor?: Swap;
|
|
37
|
+
};
|
|
38
|
+
export type Tweaks = Record<string, Tweak>; // keyed by beat index, or a <Part>'s id
|
|
39
|
+
/** The tweaks key whose `recolor` applies to the whole video. */
|
|
40
|
+
export const ALL = '__all';
|
|
41
|
+
/** Runs a swap on an element after every render (once one was ever set, so taking it off restores the colours). */
|
|
42
|
+
const useRecolor = (swap: Swap | undefined, skipParts: boolean) => {
|
|
43
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
44
|
+
const used = useRef(false);
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
if (swap && Object.keys(swap).length) used.current = true;
|
|
47
|
+
if (ref.current && used.current) recolorTree(ref.current, swap ?? {}, skipParts);
|
|
48
|
+
});
|
|
49
|
+
return ref;
|
|
50
|
+
};
|
|
29
51
|
|
|
52
|
+
/** Where scale grows from, as a share of the frame. The preview's resize handle works from the same point. */
|
|
53
|
+
export const TWEAK_ORIGIN = '50% 55%';
|
|
30
54
|
export const TWEAK_LIMITS = {scale: [0.7, 1.4], dy: [-300, 300], shift: [-12, 12], gain: [-12, 6]} as const;
|
|
31
55
|
|
|
32
56
|
/** track/toSource: optional head track for the "follow" tweak — the clip's source frame at a composition frame. */
|
|
33
57
|
type Ctx = {tweaks: Tweaks; beatAt: (frame: number) => number; beatStarts: number[]; track?: TrackFile; toSource?: (frame: number) => number | null};
|
|
34
58
|
const All = createContext<Ctx>({tweaks: {}, beatAt: () => 0, beatStarts: [0]});
|
|
35
59
|
const Here = createContext<Tweak>({});
|
|
60
|
+
type SlotMap = {behind: HTMLElement | null; front: HTMLElement | null};
|
|
61
|
+
const Slots = createContext<{slots: SlotMap; set: (name: keyof SlotMap, el: HTMLElement | null) => void}>({slots: {behind: null, front: null}, set: () => {}});
|
|
36
62
|
|
|
37
63
|
/** beatStarts: the first frame of each beat, in order. */
|
|
38
64
|
export const TweaksProvider: React.FC<{tweaks?: Tweaks; beatStarts: number[]; track?: TrackFile; toSource?: (frame: number) => number | null; children: React.ReactNode}> = ({
|
|
@@ -46,7 +72,20 @@ export const TweaksProvider: React.FC<{tweaks?: Tweaks; beatStarts: number[]; tr
|
|
|
46
72
|
for (let i = beatStarts.length - 1; i >= 0; i--) if (frame >= beatStarts[i]) return i;
|
|
47
73
|
return 0;
|
|
48
74
|
};
|
|
49
|
-
|
|
75
|
+
const [slots, setSlots] = useState<SlotMap>({behind: null, front: null});
|
|
76
|
+
const set = useCallback((name: keyof SlotMap, el: HTMLElement | null) => setSlots((s) => (s[name] === el ? s : {...s, [name]: el})), []);
|
|
77
|
+
const slotCtx = useMemo(() => ({slots, set}), [slots, set]);
|
|
78
|
+
// the whole video's colour swaps; a graphic with its own swaps does both itself (see Part)
|
|
79
|
+
const whole = useRecolor(tweaks[ALL]?.recolor, true);
|
|
80
|
+
return (
|
|
81
|
+
<All.Provider value={{tweaks, beatAt, beatStarts, track, toSource}}>
|
|
82
|
+
<Slots.Provider value={slotCtx}>
|
|
83
|
+
<div ref={whole} data-lp-root="" style={{display: 'contents'}}>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
</Slots.Provider>
|
|
87
|
+
</All.Provider>
|
|
88
|
+
);
|
|
50
89
|
};
|
|
51
90
|
|
|
52
91
|
/** The tweak of the beat at any frame — for things placed by frame number, like sound cues. */
|
|
@@ -58,6 +97,52 @@ export const useTweakAt = () => {
|
|
|
58
97
|
/** The tweak of the beat being drawn, inside a <Tweaked>. */
|
|
59
98
|
export const useHereTweak = () => useContext(Here);
|
|
60
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Where a part goes when it is sent in front of / behind the speaker. An edit with a cut-out of the person puts
|
|
102
|
+
* <Layer name="behind" /> just before the cut-out and <Layer name="front" /> just after it, in the same container.
|
|
103
|
+
*/
|
|
104
|
+
export const Layer: React.FC<{name: 'behind' | 'front'}> = ({name}) => {
|
|
105
|
+
const {set} = useContext(Slots);
|
|
106
|
+
const ref = useCallback((el: HTMLDivElement | null) => set(name, el), [name, set]);
|
|
107
|
+
return <div ref={ref} data-lp-slot={name} style={{position: 'absolute', inset: 0}} />;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One graphic the person can grab on the preview: a word, an icon, an image, a card, the captions. Its own tweak is
|
|
112
|
+
* saved under its id (tweaks["word-nobody"]): moved, resized, recoloured (text), sent in front of / behind the
|
|
113
|
+
* speaker, or hidden — and the final render reads the same file. Wrap each thing separately, so one moves alone;
|
|
114
|
+
* never wrap the speaker, the room or a full-screen card that hides a cut.
|
|
115
|
+
* <Part id="word-nobody"><Giant …>Nobody</Giant></Part>
|
|
116
|
+
*/
|
|
117
|
+
export const Part: React.FC<{id: string; children: React.ReactNode}> = ({id, children}) => {
|
|
118
|
+
const all = useContext(All).tweaks;
|
|
119
|
+
const t = all[id] ?? {};
|
|
120
|
+
const {slots} = useContext(Slots);
|
|
121
|
+
// its own swaps first, then the whole video's (a graphic moved out by a portal is outside the root's pass)
|
|
122
|
+
const own = t.recolor && Object.keys(t.recolor).length ? t.recolor : null;
|
|
123
|
+
const swap = own || t.layer ? {...(all[ALL]?.recolor ?? {}), ...(own ?? {})} : undefined;
|
|
124
|
+
const order = own ? {...own, ...Object.fromEntries(Object.entries(all[ALL]?.recolor ?? {}).filter(([k]) => !(k in own)))} : swap;
|
|
125
|
+
const ref = useRecolor(order, false);
|
|
126
|
+
const placed = t.scale || t.dy || t.dx;
|
|
127
|
+
// display: contents with nothing applied — the layout is exactly as built. Always the same <div>, so a drag never
|
|
128
|
+
// rebuilds what is inside it.
|
|
129
|
+
const style: React.CSSProperties = placed
|
|
130
|
+
? {position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', transform: `translate(${t.dx ?? 0}px, ${t.dy ?? 0}px) scale(${t.scale ?? 1})`, transformOrigin: TWEAK_ORIGIN}
|
|
131
|
+
: {display: 'contents'};
|
|
132
|
+
if (t.hide) style.visibility = 'hidden';
|
|
133
|
+
const el = (
|
|
134
|
+
<div ref={ref} data-lp-key={id} data-lp-recolor={order ? '' : undefined} style={style}>
|
|
135
|
+
{t.color ? <style>{`[data-lp-key="${id}"], [data-lp-key="${id}"] * { color: ${t.color} !important; }`}</style> : null}
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
const to = t.layer ? slots[t.layer] : null;
|
|
140
|
+
return to ? createPortal(el, to) : el;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** The captions (or any layer through the whole video) — the same as a Part. */
|
|
144
|
+
export const Movable = Part;
|
|
145
|
+
|
|
61
146
|
/**
|
|
62
147
|
* Applies the current beat's size, position and timing to what is inside it. `move={false}` only hands the
|
|
63
148
|
* tweak down (see the rules above).
|
|
@@ -81,9 +166,26 @@ export const Tweaked: React.FC<{move?: boolean; children: React.ReactNode}> = ({
|
|
|
81
166
|
}
|
|
82
167
|
follow = src !== null && src0 !== null ? followTransform(track, src, src0) : null;
|
|
83
168
|
}
|
|
169
|
+
// data-lp-beat lets the preview find which beat's graphics are under the pointer, so they can be dragged on the
|
|
170
|
+
// video. With nothing to apply it is display: contents — no box, so the layout is exactly as built. Both are the
|
|
171
|
+
// same <div>, so starting a drag never rebuilds what is inside it (the preview's selection would lose it).
|
|
172
|
+
const beat = ctx.beatAt(f);
|
|
84
173
|
const placed = (
|
|
85
174
|
<Here.Provider value={t}>
|
|
86
|
-
{move
|
|
175
|
+
{move ? (
|
|
176
|
+
<div
|
|
177
|
+
data-lp-beat={beat}
|
|
178
|
+
style={
|
|
179
|
+
t.scale || t.dy || t.dx
|
|
180
|
+
? {position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', transform: `translate(${t.dx ?? 0}px, ${t.dy ?? 0}px) scale(${t.scale ?? 1})`, transformOrigin: TWEAK_ORIGIN}
|
|
181
|
+
: {display: 'contents'}
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
{children}
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
children
|
|
188
|
+
)}
|
|
87
189
|
</Here.Provider>
|
|
88
190
|
);
|
|
89
191
|
const inner = follow ? <AbsoluteFill style={follow}>{placed}</AbsoluteFill> : placed;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The motion sheet, drawn by Remotion itself so it renders the same on every machine (no browser or font
|
|
3
|
+
* setup to depend on). scripts/plan-sheet.mjs bundles this with the stills folder as its public dir and
|
|
4
|
+
* renders one still of it.
|
|
5
|
+
*
|
|
6
|
+
* Props: {title, subtitle, tiles: [{file, n, time, frame, says?, what?, beat?}]}
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import {Composition, Img, registerRoot, staticFile} from 'remotion';
|
|
10
|
+
|
|
11
|
+
type Tile = {file: string; n: number; time: string; frame: number; says?: string; what?: string; beat?: string};
|
|
12
|
+
type Props = {title: string; subtitle: string; tiles: Tile[]; vertical: boolean};
|
|
13
|
+
|
|
14
|
+
const COLS = 4;
|
|
15
|
+
const W = 2000;
|
|
16
|
+
const PAD = 40;
|
|
17
|
+
const GAP = 22;
|
|
18
|
+
const TILE_W = (W - PAD * 2 - GAP * (COLS - 1)) / COLS;
|
|
19
|
+
const tileImgH = (vertical: boolean) => (vertical ? (TILE_W * 16) / 9 : (TILE_W * 9) / 16);
|
|
20
|
+
const LABEL_H = 170;
|
|
21
|
+
const HEAD_H = 140;
|
|
22
|
+
|
|
23
|
+
const Sheet: React.FC<Props> = ({title, subtitle, tiles, vertical}) => (
|
|
24
|
+
<div style={{width: W, background: '#121210', color: '#fff', fontFamily: '-apple-system, "Segoe UI", Helvetica, Arial, sans-serif', minHeight: '100%'}}>
|
|
25
|
+
<div style={{padding: `34px ${PAD}px 0`, height: HEAD_H, boxSizing: 'border-box'}}>
|
|
26
|
+
<div style={{fontSize: 40, fontWeight: 800}}>
|
|
27
|
+
{title} <span style={{color: 'your accent'}}>— motion sheet</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div style={{fontSize: 20, color: '#b9b8ad', marginTop: 8}}>{subtitle}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div style={{display: 'grid', gridTemplateColumns: `repeat(${COLS}, ${TILE_W}px)`, gap: GAP, padding: `0 ${PAD}px ${PAD}px`}}>
|
|
32
|
+
{tiles.map((t) => (
|
|
33
|
+
<div key={t.n} style={{background: '#1d1d1a', borderRadius: 16, overflow: 'hidden'}}>
|
|
34
|
+
<Img src={staticFile(t.file)} style={{display: 'block', width: TILE_W, height: tileImgH(vertical), objectFit: 'cover'}} />
|
|
35
|
+
<div style={{display: 'flex', gap: 14, padding: '14px 16px 18px', height: LABEL_H, boxSizing: 'border-box'}}>
|
|
36
|
+
<div style={{flex: 'none', width: 44, height: 44, borderRadius: 999, background: 'your accent', color: '#121210', fontWeight: 800, fontSize: 22, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>{t.n}</div>
|
|
37
|
+
<div style={{minWidth: 0}}>
|
|
38
|
+
<div style={{fontSize: 15, color: '#9a9989', fontWeight: 600}}>
|
|
39
|
+
{t.time} · frame {t.frame}
|
|
40
|
+
</div>
|
|
41
|
+
{t.says ? <div style={{fontSize: 18, fontWeight: 800, margin: '3px 0 4px'}}>{t.says}</div> : null}
|
|
42
|
+
{t.what ? <div style={{fontSize: 16, color: '#d9d8cf', lineHeight: 1.35}}>{t.what}</div> : null}
|
|
43
|
+
{t.beat ? <div style={{marginTop: 8, display: 'inline-block', fontSize: 13, fontWeight: 700, color: '#121210', background: '#e4fc9e', borderRadius: 999, padding: '3px 10px'}}>{t.beat}</div> : null}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
registerRoot(() => (
|
|
53
|
+
<Composition
|
|
54
|
+
id="PlanSheet"
|
|
55
|
+
component={Sheet}
|
|
56
|
+
fps={30}
|
|
57
|
+
durationInFrames={1}
|
|
58
|
+
width={W}
|
|
59
|
+
height={1000}
|
|
60
|
+
defaultProps={{title: '', subtitle: '', tiles: [], vertical: true}}
|
|
61
|
+
calculateMetadata={({props}) => {
|
|
62
|
+
const rows = Math.ceil(props.tiles.length / COLS);
|
|
63
|
+
return {height: Math.ceil(HEAD_H + rows * (tileImgH(props.vertical) + LABEL_H) + (rows - 1) * GAP + PAD)};
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
));
|
|
@@ -9,7 +9,7 @@ import {EASE} from '../../design/motion';
|
|
|
9
9
|
import {COLOR, FONT, WEIGHT} from '../../design/tokens';
|
|
10
10
|
import {progress} from '../../lib/animate';
|
|
11
11
|
import {sampleTrack, type TrackFile} from '../../lib/track';
|
|
12
|
-
import {TweaksProvider, Tweaked, type Tweaks} from '../../lib/tweaks';
|
|
12
|
+
import {Movable, TweaksProvider, Tweaked, type Tweaks} from '../../lib/tweaks';
|
|
13
13
|
import {BEATS, FRAMES, WORDS} from './beats';
|
|
14
14
|
import headTrack from './head-track.json';
|
|
15
15
|
import SAVED_TWEAKS from './tweaks.json';
|
|
@@ -168,7 +168,9 @@ export const WhatsNewEdit: React.FC<{tweaks?: Tweaks}> = ({tweaks = SAVED_TWEAKS
|
|
|
168
168
|
</Tweaked>
|
|
169
169
|
|
|
170
170
|
{/* 3 · Captions, word by word, all the way through */}
|
|
171
|
-
<
|
|
171
|
+
<Movable id="captions">
|
|
172
|
+
<Captions words={words} placement={() => ({x: 240, y: 610, width: 800, align: 'center', size: 34})} />
|
|
173
|
+
</Movable>
|
|
172
174
|
</AbsoluteFill>
|
|
173
175
|
</TweaksProvider>
|
|
174
176
|
);
|