creator-editing-studio 1.3.7 → 1.4.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.
Files changed (34) hide show
  1. package/bin/finish.mjs +132 -0
  2. package/bin/init.mjs +5 -1
  3. package/bin/update.mjs +18 -0
  4. package/history.json +1 -1
  5. package/package.json +2 -1
  6. package/template/CLAUDE.md +31 -7
  7. package/template/package.json +3 -1
  8. package/template/preview/lib.mjs +164 -19
  9. package/template/preview/player.html +71 -68
  10. package/template/preview/server.mjs +246 -23
  11. package/template/remotion.config.ts +5 -0
  12. package/template/scripts/approve.mjs +39 -0
  13. package/template/scripts/lib/plan-gate.mjs +73 -0
  14. package/template/scripts/plan-sheet.mjs +99 -0
  15. package/template/src/components/CommentBox.tsx +31 -0
  16. package/template/src/components/FilmBurn.tsx +48 -0
  17. package/template/src/components/FlashBloom.tsx +45 -0
  18. package/template/src/components/MergeToPill.tsx +56 -0
  19. package/template/src/components/NotificationDrop.tsx +49 -0
  20. package/template/src/components/PillCaptions.tsx +62 -0
  21. package/template/src/components/ReelRing.tsx +81 -0
  22. package/template/src/components/WhipScene.tsx +90 -0
  23. package/template/src/design/motion.ts +8 -0
  24. package/template/src/lib/brand.tsx +89 -0
  25. package/template/src/lib/layout.tsx +113 -0
  26. package/template/src/lib/recolor.ts +173 -0
  27. package/template/src/lib/tweaks.tsx +108 -6
  28. package/template/src/plan/entry.tsx +66 -0
  29. package/template/src/videos/whats-new/WhatsNewEdit.tsx +177 -0
  30. package/template/src/videos/whats-new/beats.ts +44 -0
  31. package/template/src/videos/whats-new/head-track.json +5195 -0
  32. package/template/src/videos/whats-new/preview.tsx +36 -0
  33. package/template/src/videos/whats-new/tweaks.json +1 -0
  34. package/template/src/videos/whats-new/words.json +1 -0
@@ -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 = {scale?: number; dy?: number; shift?: number; gain?: number; mute?: boolean; icon?: Record<string, string>; follow?: boolean};
28
- export type Tweaks = Record<string, Tweak>; // keyed by beat index
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
- return <All.Provider value={{tweaks, beatAt, beatStarts, track, toSource}}>{children}</All.Provider>;
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 && (t.scale || t.dy) ? <AbsoluteFill style={{transform: `translateY(${t.dy ?? 0}px) scale(${t.scale ?? 1})`, transformOrigin: '50% 55%'}}>{children}</AbsoluteFill> : children}
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
+ ));
@@ -0,0 +1,177 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill, useCurrentFrame} from 'remotion';
3
+ import {Captions} from '../../components/Captions';
4
+ import {HeadPin} from '../../components/HeadPin';
5
+ import {DataLine} from '../../components/DataLine';
6
+ import {MorphState} from '../../components/MorphState';
7
+ import {TypeAhead} from '../../components/TypeAhead';
8
+ import {EASE} from '../../design/motion';
9
+ import {COLOR, FONT, WEIGHT} from '../../design/tokens';
10
+ import {progress} from '../../lib/animate';
11
+ import {sampleTrack, type TrackFile} from '../../lib/track';
12
+ import {Movable, TweaksProvider, Tweaked, type Tweaks} from '../../lib/tweaks';
13
+ import {BEATS, FRAMES, WORDS} from './beats';
14
+ import headTrack from './head-track.json';
15
+ import SAVED_TWEAKS from './tweaks.json';
16
+
17
+ /**
18
+ * whats-new — the demo every studio carries: 25 seconds, one new effect per beat, each named by a small
19
+ * label. It opens in the live preview after an update so the person SEES what arrived and can try the
20
+ * buttons on something that is not their own work.
21
+ *
22
+ * NO REAL PERSON. No footage and no voice ship — only the words (as captions) and the motion of a head
23
+ * recorded once (head-track.json: positions, nothing else). A plain stand-in silhouette rides that path,
24
+ * so motion tracking and "Follow head" have something to follow. Colours and type are the studio's own
25
+ * tokens, so it appears in the customer's brand.
26
+ *
27
+ * 1280×720, the size the track was recorded at, so its pixels are the composition's pixels.
28
+ */
29
+ export {FRAMES};
30
+ export const WIDTH = 1280;
31
+ export const HEIGHT = 720;
32
+ const TRACK = headTrack as TrackFile;
33
+ /** One unbroken clip: composition frame = source frame. */
34
+ export const toSource = (f: number) => (f >= 0 && f < FRAMES ? f : null);
35
+
36
+ const W = (i: number) => WORDS[i].f;
37
+ const SHADOW = '0 2px 18px rgba(0,0,0,0.45)';
38
+
39
+ /** The stand-in: a soft head-and-shoulders shape on the recorded head path (the track sits at the hairline). */
40
+ const StandIn: React.FC = () => {
41
+ const f = useCurrentFrame();
42
+ const p = sampleTrack(TRACK, f);
43
+ if (!p) return null;
44
+ const fill = 'rgba(255,255,255,0.10)';
45
+ return (
46
+ <div style={{position: 'absolute', left: p.x, top: p.y, transform: `translate(-50%, 0) scale(${p.s}) rotate(${p.r}deg)`, transformOrigin: '50% 140px'}}>
47
+ <div style={{position: 'absolute', left: -125, top: -10, width: 250, height: 310, borderRadius: '50% 50% 46% 46%', background: fill}} />
48
+ <div style={{position: 'absolute', left: -330, top: 330, width: 660, height: 420, borderRadius: '300px 300px 0 0', background: fill}} />
49
+ </div>
50
+ );
51
+ };
52
+
53
+ /** In over 8 frames at `from`, out over 6 before `to`. */
54
+ const useInOut = (from: number, to: number) => {
55
+ const f = useCurrentFrame();
56
+ if (f < from || f >= to) return 0;
57
+ return Math.min(progress(f, from, 8, EASE.out), 1 - progress(f, to - 6, 6, EASE.in));
58
+ };
59
+
60
+ /** The effect's name, top left, for the length of its beat. */
61
+ const Label: React.FC<{text: string; from: number; to: number}> = ({text, from, to}) => {
62
+ const o = useInOut(from, to);
63
+ if (!o) return null;
64
+ return (
65
+ <div style={{position: 'absolute', left: 48, top: 40, opacity: o, transform: `translateY(${(1 - o) * -10}px)`, display: 'flex', alignItems: 'center', gap: 10, fontFamily: FONT.sans}}>
66
+ <span style={{background: COLOR.accent, color: COLOR.onAccent, fontSize: 15, fontWeight: WEIGHT.bold, letterSpacing: '0.08em', padding: '5px 10px', borderRadius: 999}}>NEW</span>
67
+ <span style={{color: COLOR.fgOnDark, fontSize: 24, fontWeight: WEIGHT.semibold, textShadow: SHADOW}}>{text}</span>
68
+ </div>
69
+ );
70
+ };
71
+
72
+ /** Beat 4: the jobs, landing one by one as they are named. */
73
+ const JOBS: [string, number][] = [
74
+ ['Cut the gaps', W(41)],
75
+ ['Add captions', W(44)],
76
+ ['Bring in b-roll', W(46)],
77
+ ['Zooms', W(49)],
78
+ ['Motion graphics', W(53)],
79
+ ];
80
+ const Jobs: React.FC<{to: number}> = ({to}) => {
81
+ const f = useCurrentFrame();
82
+ const out = 1 - progress(f, to - 6, 6, EASE.in);
83
+ if (f >= to || f < JOBS[0][1] - 2) return null;
84
+ return (
85
+ <div style={{position: 'absolute', left: 930, top: 170, display: 'flex', flexDirection: 'column', gap: 18, fontFamily: FONT.sans, opacity: out}}>
86
+ {JOBS.map(([text, at]) => {
87
+ const p = progress(f, at - 2, 9, EASE.overshoot);
88
+ if (p <= 0) return null;
89
+ return (
90
+ <div key={text} style={{display: 'flex', alignItems: 'center', gap: 14, opacity: Math.min(1, p * 1.6), transform: `translateX(${(1 - Math.min(1, p)) * 40}px)`}}>
91
+ <span style={{width: 14, height: 14, borderRadius: 999, background: COLOR.accent, boxShadow: SHADOW}} />
92
+ <span style={{color: COLOR.fgOnDark, fontSize: 34, fontWeight: WEIGHT.bold, textShadow: SHADOW}}>{text}</span>
93
+ </div>
94
+ );
95
+ })}
96
+ </div>
97
+ );
98
+ };
99
+
100
+ /** A slow push-in on "zooms", easing back out before the next beat. */
101
+ const zoomAt = (f: number) => {
102
+ const inP = progress(f, W(52) - 4, 18, EASE.inOut);
103
+ const outP = progress(f, W(56) - 16, 14, EASE.inOut);
104
+ return 1 + 0.07 * inP * (1 - outP);
105
+ };
106
+
107
+ export const WhatsNewEdit: React.FC<{tweaks?: Tweaks}> = ({tweaks = SAVED_TWEAKS as Tweaks}) => {
108
+ const f = useCurrentFrame();
109
+ const [hook, simple, , list, morph, data] = BEATS;
110
+ const words = React.useMemo(() => WORDS.map((w) => ({text: w.text, start: w.f / 30, end: w.t / 30, paragraph: 0, matched: true})), []);
111
+ return (
112
+ <TweaksProvider tweaks={tweaks} beatStarts={BEATS.map((b) => b.fromF)} track={TRACK} toSource={toSource}>
113
+ <AbsoluteFill style={{background: COLOR.surfaceDark}}>
114
+ <AbsoluteFill style={{transform: `scale(${zoomAt(f)})`, transformOrigin: '50% 40%', background: 'radial-gradient(ellipse at 50% 30%, rgba(255,255,255,0.07), transparent 65%)'}}>
115
+ <StandIn />
116
+ </AbsoluteFill>
117
+
118
+ <Tweaked>
119
+ {BEATS.map((b) => (
120
+ <Label key={b.name} text={b.name} from={b.fromF} to={b.toF} />
121
+ ))}
122
+
123
+ {/* 1 · Motion tracking: the tag is pinned to the head. */}
124
+ <HeadPin track={TRACK} toSource={toSource} from={W(2)} to={hook.toF} dx={350} dy={90}>
125
+ <div style={{fontFamily: FONT.sans, color: COLOR.onAccent, background: COLOR.accent, fontSize: 28, fontWeight: WEIGHT.bold, padding: '8px 18px', borderRadius: 999, boxShadow: SHADOW, whiteSpace: 'nowrap'}}>
126
+ ← this follows your head
127
+ </div>
128
+ </HeadPin>
129
+
130
+ {/* 2 · Type-ahead */}
131
+ {f >= simple.fromF && f < simple.toF ? (
132
+ <div style={{position: 'absolute', left: 60, top: 300, opacity: 1 - progress(f, simple.toF - 6, 6, EASE.in)}}>
133
+ <TypeAhead text="The idea is pretty simple." at={W(16)} speed={1.1} colour={COLOR.onAccent} ghost="rgba(0,0,0,0.28)" background={COLOR.accent} fontSize={34} fontFamily={FONT.sans} fontWeight={WEIGHT.bold} />
134
+ </div>
135
+ ) : null}
136
+
137
+ {/* 4 · The list that builds */}
138
+ <Jobs to={list.toF} />
139
+
140
+ {/* 5 · Morph: the same pill relabels itself */}
141
+ {f >= W(59) - 4 && f < morph.toF ? (
142
+ <div style={{position: 'absolute', left: 70, top: 300, opacity: Math.min(progress(f, W(59) - 4, 8, EASE.out), 1 - progress(f, morph.toF - 6, 6, EASE.in))}}>
143
+ <MorphState
144
+ at={W(66)}
145
+ from={{width: 210, height: 64, background: 'rgba(255,255,255,0.92)', colour: '#111', children: <span style={{fontFamily: FONT.sans, fontSize: 28, fontWeight: WEIGHT.bold}}>Raw clip</span>}}
146
+ to={{width: 300, height: 64, background: COLOR.accent, colour: COLOR.onAccent, children: <span style={{fontFamily: FONT.sans, fontSize: 28, fontWeight: WEIGHT.bold}}>Ready to post ✓</span>}}
147
+ />
148
+ </div>
149
+ ) : null}
150
+
151
+ {/* 6 · Live data line: time spent editing, dropping on "saves" */}
152
+ {f >= data.fromF ? (
153
+ <div style={{position: 'absolute', left: 900, top: 190, fontFamily: FONT.sans, opacity: progress(f, data.fromF, 8, EASE.out)}}>
154
+ <div style={{color: COLOR.fgOnDark, fontSize: 24, fontWeight: WEIGHT.semibold, textShadow: SHADOW, marginBottom: 10}}>Hours spent editing</div>
155
+ <DataLine
156
+ from={[0.85, 0.8, 0.88, 0.78, 0.84, 0.8]}
157
+ to={[0.85, 0.62, 0.44, 0.3, 0.18, 0.1]}
158
+ drawAt={data.fromF + 2}
159
+ drawFrames={14}
160
+ at={W(70)}
161
+ width={320}
162
+ height={150}
163
+ colour={COLOR.fgOnDark}
164
+ toColour={COLOR.accent}
165
+ />
166
+ </div>
167
+ ) : null}
168
+ </Tweaked>
169
+
170
+ {/* 3 · Captions, word by word, all the way through */}
171
+ <Movable id="captions">
172
+ <Captions words={words} placement={() => ({x: 240, y: 610, width: 800, align: 'center', size: 34})} />
173
+ </Movable>
174
+ </AbsoluteFill>
175
+ </TweaksProvider>
176
+ );
177
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * whats-new — the short demo every studio carries, so the person can see the newest effects working on a
3
+ * short sample and try the live preview's buttons on it before they edit anything of their own.
4
+ *
5
+ * Each beat shows one effect and names it in a small label, so nobody needs the vocabulary to follow.
6
+ * A beat runs from its first word to the next beat's first word.
7
+ */
8
+ import data from './words.json';
9
+
10
+ export const WORDS = data.words as {text: string; f: number; t: number}[];
11
+ export const FRAMES = data.frames;
12
+
13
+ /** [first word, name, what is on screen (and the effect it shows)] */
14
+ const PLAN: [number, string, string][] = [
15
+ [0, 'Motion tracking', 'A label rides with the head — moves, scales and tilts with it'],
16
+ [15, 'Type-ahead', 'The sentence types into a pill that grows with it, the rest already faint'],
17
+ [21, 'Word-by-word captions', 'Captions sharpen in word by word, on the spoken timing'],
18
+ [32, 'A list that builds', 'Each job lands as it is named; a slow zoom on "zooms"'],
19
+ [56, 'Morph', 'One pill turns from "Raw clip" into "Ready to post" — the same object, relabelled'],
20
+ [68, 'Live data line', 'A line draws on, then drops on "saves"'],
21
+ ];
22
+
23
+ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
24
+
25
+ export const BEATS = PLAN.map(([a, name, beat], i) => {
26
+ const fromF = i === 0 ? 0 : WORDS[a].f;
27
+ const next = PLAN[i + 1];
28
+ const toF = next ? WORDS[next[0]].f : FRAMES;
29
+ const last = next ? next[0] - 1 : WORDS.length - 1;
30
+ return {
31
+ name,
32
+ beat,
33
+ icons: [] as string[],
34
+ fromF,
35
+ toF,
36
+ from: fromF / 30,
37
+ to: toF / 30,
38
+ time: `${mmss(fromF / 30)}–${mmss(toF / 30)}`,
39
+ says: WORDS.slice(a, last + 1).map((w) => w.text).join(' '),
40
+ };
41
+ });
42
+
43
+ export type {Tweak, Tweaks} from '../../lib/tweaks';
44
+ export {TWEAK_LIMITS as LIMITS} from '../../lib/tweaks';