creator-editing-studio 1.4.0 → 1.4.2
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 +26 -5
- package/template/package.json +1 -0
- package/template/preview/lib.mjs +110 -7
- package/template/preview/player.html +72 -70
- package/template/preview/server.mjs +58 -7
- package/template/preview/shell.html +97 -0
- package/template/scripts/final-check.mjs +69 -0
- package/template/scripts/lib/plan-gate.mjs +28 -2
- package/template/src/lib/fontChoices.ts +72 -0
- package/template/src/lib/swaps.ts +152 -0
- package/template/src/lib/tweaks.tsx +45 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Check the FINISHED file — after every render, before saying it is done.
|
|
4
|
+
*
|
|
5
|
+
* npm run final-check -- out/my-video/final.mp4 [--fps=30] [--frames=871]
|
|
6
|
+
*
|
|
7
|
+
* Writes <file>.check.jpg: one tile per second of the delivered video, so a word that is missing,
|
|
8
|
+
* cut off or pushed off-screen by a camera move shows up (it did, 2026-11-05: a word behind the
|
|
9
|
+
* speaker was scaled off the top by the push-in and appeared for 8 frames — no still of the edit
|
|
10
|
+
* showed it, only the finished file did). Then reports and fails on:
|
|
11
|
+
* - frame count (against --frames when given) and a video + audio stream being present
|
|
12
|
+
* - loudness outside -17…-15 LUFS, true peak above -0.9 dBTP
|
|
13
|
+
* - silent gaps over 0.6 s while the video is still running (a dropped voice join)
|
|
14
|
+
* Look at the contact sheet: the numbers cannot see a misplaced word.
|
|
15
|
+
*/
|
|
16
|
+
import {existsSync} from 'node:fs';
|
|
17
|
+
import {FFMPEG, FFPROBE, run} from './lib/media.mjs';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const file = args.find((a) => !a.startsWith('--'));
|
|
21
|
+
const opt = (k) => args.find((a) => a.startsWith(`--${k}=`))?.split('=')[1];
|
|
22
|
+
if (!file || !existsSync(file)) {
|
|
23
|
+
console.error('Usage: npm run final-check -- <rendered.mp4> [--frames=<expected>]');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const problems = [];
|
|
27
|
+
|
|
28
|
+
// streams
|
|
29
|
+
const info = JSON.parse(run(FFPROBE, ['-v', 'error', '-count_frames', '-show_entries', 'stream=codec_type,width,height,nb_read_frames,r_frame_rate:format=duration', '-of', 'json', file]).out);
|
|
30
|
+
const v = info.streams.find((s) => s.codec_type === 'video');
|
|
31
|
+
const a = info.streams.find((s) => s.codec_type === 'audio');
|
|
32
|
+
const dur = +info.format.duration;
|
|
33
|
+
if (!v) problems.push('no video stream');
|
|
34
|
+
if (!a) problems.push('no audio stream');
|
|
35
|
+
const frames = v ? +v.nb_read_frames : 0;
|
|
36
|
+
const want = opt('frames') ? +opt('frames') : null;
|
|
37
|
+
if (want && frames !== want) problems.push(`${frames} frames, expected ${want}`);
|
|
38
|
+
console.log(`video ${v ? `${v.width}×${v.height}, ${frames} frames, ${v.r_frame_rate} fps` : '—'} · ${dur.toFixed(2)} s`);
|
|
39
|
+
|
|
40
|
+
// loudness + true peak
|
|
41
|
+
if (a) {
|
|
42
|
+
const out = run(FFMPEG, ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-']).err;
|
|
43
|
+
const tail = out.slice(out.lastIndexOf('Summary:'));
|
|
44
|
+
const I = +(/I:\s+(-?[\d.]+) LUFS/.exec(tail)?.[1] ?? NaN);
|
|
45
|
+
const TP = +(/Peak:\s+(-?[\d.]+) dBFS/.exec(tail)?.[1] ?? NaN);
|
|
46
|
+
console.log(`audio ${I} LUFS integrated · true peak ${TP} dBTP`);
|
|
47
|
+
if (!(I >= -17 && I <= -15)) problems.push(`loudness ${I} LUFS (deliver at -16)`);
|
|
48
|
+
if (!(TP <= -0.9)) problems.push(`true peak ${TP} dBTP (keep at or under -1)`);
|
|
49
|
+
|
|
50
|
+
// silences while the picture still runs (the last 4 s may be a silent end card)
|
|
51
|
+
const sil = run(FFMPEG, ['-hide_banner', '-nostats', '-i', file, '-af', 'silencedetect=n=-45dB:d=0.6', '-f', 'null', '-']).err;
|
|
52
|
+
const gaps = [...sil.matchAll(/silence_start: ([\d.]+)/g)].map((m) => +m[1]).filter((t) => t > 0.3 && t < dur - 4);
|
|
53
|
+
if (gaps.length) problems.push(`silent gap at ${gaps.map((t) => t.toFixed(2) + ' s').join(', ')} — check the voice joins`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// contact sheet: one tile per second, 8 across, read left to right (tile n = second n; no burned-in
|
|
57
|
+
// timestamps — ffmpeg's drawtext needs a fontconfig most Windows machines lack)
|
|
58
|
+
const sheet = file.replace(/\.[^.]+$/, '') + '.check.jpg';
|
|
59
|
+
const tiles = Math.ceil(dur);
|
|
60
|
+
const rows = Math.ceil(tiles / 8);
|
|
61
|
+
const made = run(FFMPEG, ['-y', '-v', 'error', '-i', file, '-vf', `fps=1,scale=240:-1,tile=8x${rows}`, '-frames:v', '1', sheet]);
|
|
62
|
+
if (!made.ok) problems.push('contact sheet failed: ' + made.err.trim());
|
|
63
|
+
console.log(`sheet ${sheet} (${tiles} tiles) — LOOK AT IT before reporting`);
|
|
64
|
+
|
|
65
|
+
if (problems.length) {
|
|
66
|
+
console.error('\n✗ Not ready:\n - ' + problems.join('\n - '));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log('\n✓ File checks pass. Now read the contact sheet.');
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Fixes to an approved edit render freely; only a NEW sheet (a new plan) asks for a new yes.
|
|
17
17
|
*/
|
|
18
18
|
import {createHash} from 'node:crypto';
|
|
19
|
-
import {existsSync, readFileSync} from 'node:fs';
|
|
19
|
+
import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs';
|
|
20
20
|
import {join, resolve} from 'node:path';
|
|
21
21
|
|
|
22
22
|
const EXEMPT = /(Test|Board|Sheet)$|^Sample$/;
|
|
@@ -69,5 +69,31 @@ export function checkPlanGate(argv) {
|
|
|
69
69
|
if (hash !== approved.sheetHash) refuse('the motion sheet changed after it was approved — that is a new plan, and it needs a new yes.');
|
|
70
70
|
// Then they must have SEEN it move: the live preview, opened after the yes (preview/server.mjs writes this).
|
|
71
71
|
const previewed = join(dir, 'previewed.json');
|
|
72
|
-
if (!existsSync(previewed)
|
|
72
|
+
if (!existsSync(previewed)) refuse('they approved the plan but have not seen it in the live preview since — open it (preview_edit) and render only when they press Render.');
|
|
73
|
+
const seen = JSON.parse(readFileSync(previewed, 'utf8'));
|
|
74
|
+
if (seen.at < approved.at) refuse('they approved the plan but have not seen it in the live preview since — open it (preview_edit) and render only when they press Render.');
|
|
75
|
+
// A hard gate on the CURRENT edit, not just the plan (2026-11-05): any change to the video's files after the last
|
|
76
|
+
// preview means they have not seen what would render. tweaks.json is excluded — the preview itself writes it.
|
|
77
|
+
const changed = seen.project ? newestChange(resolve('src', 'videos', seen.project)) : null;
|
|
78
|
+
if (changed && changed.at > Date.parse(seen.at)) {
|
|
79
|
+
refuse(`the edit changed after they last saw it (${changed.file}) — open the live preview again (preview_edit) so they see this version, and render only when they press Render.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The most recently changed file of a video's source folder, ignoring the preview's own tweaks.json. */
|
|
84
|
+
function newestChange(dir) {
|
|
85
|
+
if (!existsSync(dir)) return null;
|
|
86
|
+
let best = null;
|
|
87
|
+
const walk = (d) => {
|
|
88
|
+
for (const e of readdirSync(d, {withFileTypes: true})) {
|
|
89
|
+
const p = join(d, e.name);
|
|
90
|
+
if (e.isDirectory()) walk(p);
|
|
91
|
+
else if (e.name !== 'tweaks.json') {
|
|
92
|
+
const at = statSync(p).mtimeMs;
|
|
93
|
+
if (!best || at > best.at) best = {file: p, at};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(dir);
|
|
98
|
+
return best;
|
|
73
99
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fonts the live preview offers beside the brand's own (2026-11-05, his ask: "click on the text, a dropdown of
|
|
3
|
+
* fonts, brand on top"). The brand's fonts are whatever the edit and tokens.ts load; these are the extras.
|
|
4
|
+
*
|
|
5
|
+
* Two halves, one family name:
|
|
6
|
+
* - the preview (a Claude chat, no internet) carries small copies of these files inside its page and registers
|
|
7
|
+
* them under exactly these names (preview/ui/gen, made from @fontsource by preview/ui/make-assets.mjs);
|
|
8
|
+
* - the final render loads the same family from Google Fonts here, but only the ones a tweak actually uses.
|
|
9
|
+
* Keep the names identical in both, or the render falls back to a system font.
|
|
10
|
+
*/
|
|
11
|
+
import {loadFont as anton} from '@remotion/google-fonts/Anton';
|
|
12
|
+
import {loadFont as bebasNeue} from '@remotion/google-fonts/BebasNeue';
|
|
13
|
+
import {loadFont as bricolage} from '@remotion/google-fonts/BricolageGrotesque';
|
|
14
|
+
import {loadFont as caveat} from '@remotion/google-fonts/Caveat';
|
|
15
|
+
import {loadFont as cormorant} from '@remotion/google-fonts/CormorantGaramond';
|
|
16
|
+
import {loadFont as dmSans} from '@remotion/google-fonts/DMSans';
|
|
17
|
+
import {loadFont as dmSerif} from '@remotion/google-fonts/DMSerifDisplay';
|
|
18
|
+
import {loadFont as instrumentSerif} from '@remotion/google-fonts/InstrumentSerif';
|
|
19
|
+
import {loadFont as inter} from '@remotion/google-fonts/Inter';
|
|
20
|
+
import {loadFont as montserrat} from '@remotion/google-fonts/Montserrat';
|
|
21
|
+
import {loadFont as playfair} from '@remotion/google-fonts/PlayfairDisplay';
|
|
22
|
+
import {loadFont as tiro} from '@remotion/google-fonts/TiroDevanagariHindi';
|
|
23
|
+
|
|
24
|
+
type Loader = (style?: 'normal' | 'italic', opts?: {weights?: string[]; subsets?: string[]}) => unknown;
|
|
25
|
+
export type FontChoice = {family: string; kind: string; generic: string; load: () => void};
|
|
26
|
+
|
|
27
|
+
const W = ['400', '500', '600', '700', '800'];
|
|
28
|
+
const make = (family: string, kind: string, generic: string, loader: Loader, weights: string[], subsets: string[], italic = false): FontChoice => ({
|
|
29
|
+
family,
|
|
30
|
+
kind,
|
|
31
|
+
generic,
|
|
32
|
+
load: () => {
|
|
33
|
+
loader('normal', {weights, subsets});
|
|
34
|
+
if (italic) loader('italic', {weights, subsets});
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** In the order the dropdown shows them, after the brand's fonts. */
|
|
39
|
+
export const FONT_CHOICES: FontChoice[] = [
|
|
40
|
+
make('Inter', 'Clean sans', 'sans-serif', inter as Loader, W, ['latin']),
|
|
41
|
+
make('DM Sans', 'Clean sans', 'sans-serif', dmSans as Loader, W, ['latin']),
|
|
42
|
+
make('Montserrat', 'Bold sans', 'sans-serif', montserrat as Loader, W, ['latin']),
|
|
43
|
+
make('Bricolage Grotesque', 'Editorial sans', 'sans-serif', bricolage as Loader, W, ['latin']),
|
|
44
|
+
make('Playfair Display', 'Serif', 'serif', playfair as Loader, W, ['latin']),
|
|
45
|
+
make('Cormorant Garamond', 'Serif', 'serif', cormorant as Loader, ['400', '500', '600', '700'], ['latin']),
|
|
46
|
+
make('Instrument Serif', 'Serif', 'serif', instrumentSerif as Loader, ['400'], ['latin'], true),
|
|
47
|
+
make('DM Serif Display', 'Serif', 'serif', dmSerif as Loader, ['400'], ['latin'], true),
|
|
48
|
+
make('Anton', 'Tall & loud', 'sans-serif', anton as Loader, ['400'], ['latin']),
|
|
49
|
+
make('Bebas Neue', 'Tall & loud', 'sans-serif', bebasNeue as Loader, ['400'], ['latin']),
|
|
50
|
+
make('Caveat', 'Handwritten', 'cursive', caveat as Loader, ['400', '500', '600', '700'], ['latin']),
|
|
51
|
+
make('Tiro Devanagari Hindi', 'Hindi / Sanskrit', 'serif', tiro as Loader, ['400'], ['latin', 'devanagari']),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const loaded = new Set<string>();
|
|
55
|
+
/** Inside the live preview the files come with the page — never reach for Google there (the chat blocks it). */
|
|
56
|
+
const inPreview = () => typeof window !== 'undefined' && !!(window as unknown as {__LP_DATA?: unknown}).__LP_DATA;
|
|
57
|
+
|
|
58
|
+
/** Load the chosen families for a render or the Remotion studio. Safe to call on every frame. */
|
|
59
|
+
export function loadFontChoices(families: Iterable<string>) {
|
|
60
|
+
if (inPreview()) return;
|
|
61
|
+
for (const f of families) {
|
|
62
|
+
if (loaded.has(f)) continue;
|
|
63
|
+
loaded.add(f);
|
|
64
|
+
FONT_CHOICES.find((c) => c.family === f)?.load();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The CSS font-family for a chosen family: the family, then a sensible fallback of the same kind. */
|
|
69
|
+
export const fontStack = (family: string) => {
|
|
70
|
+
const c = FONT_CHOICES.find((x) => x.family === family);
|
|
71
|
+
return `"${family}", ${c?.generic ?? 'sans-serif'}`;
|
|
72
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font and icon swaps from the live preview (2026-11-05, his ask: a Photoshop-style bar on whatever is clicked —
|
|
3
|
+
* change the font, swap the icon, "just replace and move on, and if you don't like it just undo").
|
|
4
|
+
*
|
|
5
|
+
* Both work on what the edit DREW, after every frame, like the colour swaps (recolor.ts), so any edit responds without
|
|
6
|
+
* being written for it, and the final render applies the same tweaks.json:
|
|
7
|
+
*
|
|
8
|
+
* refontTree(root, {your display typeface: 'Playfair Display'}) every text drawn in your display typeface inside root is set in Playfair instead
|
|
9
|
+
* (the original is remembered per element, so it never drifts)
|
|
10
|
+
* iconPass(root, {<sig>: {body, w, h}}) an <svg> the edit drew is hidden and the chosen icon is drawn in
|
|
11
|
+
* exactly its place, its colour and its glow kept. <sig> is a short
|
|
12
|
+
* signature of the original drawing, so every copy of that icon in
|
|
13
|
+
* the graphic swaps, whichever frame it appears on.
|
|
14
|
+
*
|
|
15
|
+
* The edit's own elements are never rewritten (React owns them): the swapped icon is a sibling the edit doesn't know
|
|
16
|
+
* about, and the original is only hidden with an attribute.
|
|
17
|
+
*/
|
|
18
|
+
import {fontStack} from './fontChoices';
|
|
19
|
+
|
|
20
|
+
export type IconPick = {name: string; body: string; w: number; h: number};
|
|
21
|
+
export type IconSwaps = Record<string, IconPick>;
|
|
22
|
+
export type FontSwaps = Record<string, string>;
|
|
23
|
+
|
|
24
|
+
/* ----------------------------------------------------------------------------- fonts */
|
|
25
|
+
|
|
26
|
+
const primary = (ff: string) => (ff.split(',')[0] ?? '').trim().replace(/^["']|["']$/g, '');
|
|
27
|
+
/** The family each element was drawn in before any swap (computed, so inherited fonts count). */
|
|
28
|
+
const origFamily = new WeakMap<Element, string>();
|
|
29
|
+
const origInline = new WeakMap<Element, string>();
|
|
30
|
+
const hasOwnText = (el: Element) => Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent?.trim());
|
|
31
|
+
|
|
32
|
+
export function familyOf(el: Element): string {
|
|
33
|
+
const known = origFamily.get(el);
|
|
34
|
+
if (known) return known;
|
|
35
|
+
const f = primary(getComputedStyle(el).fontFamily);
|
|
36
|
+
origFamily.set(el, f);
|
|
37
|
+
return f;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The family the text inside an element was drawn in (its first piece of text), before any swap. */
|
|
41
|
+
export function textFamilyIn(root: Element): string | null {
|
|
42
|
+
for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) if (hasOwnText(el)) return familyOf(el);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Apply font swaps to every text inside root. `swapFor(el)` gives the swaps that hold where el is (a graphic's own,
|
|
48
|
+
* then the whole video's), so one pass serves both. Safe to call every frame; empty swaps put the originals back.
|
|
49
|
+
*/
|
|
50
|
+
export function refontTree(root: Element, swapFor: (el: Element) => FontSwaps | undefined) {
|
|
51
|
+
for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
|
|
52
|
+
if (!(el instanceof HTMLElement) || !hasOwnText(el)) continue;
|
|
53
|
+
const swap = swapFor(el);
|
|
54
|
+
if (!swap && !origInline.has(el)) continue;
|
|
55
|
+
const from = familyOf(el);
|
|
56
|
+
const to = swap?.[from];
|
|
57
|
+
if (!origInline.has(el)) origInline.set(el, el.style.fontFamily);
|
|
58
|
+
const want = to ? fontStack(to) : origInline.get(el)!;
|
|
59
|
+
if (el.style.fontFamily !== want) el.style.fontFamily = want;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ----------------------------------------------------------------------------- icons */
|
|
64
|
+
|
|
65
|
+
const sigs = new WeakMap<Element, string>();
|
|
66
|
+
/** A short signature of an icon's drawing: same picture → same signature, on any frame. */
|
|
67
|
+
export function iconSig(svg: Element): string {
|
|
68
|
+
const known = sigs.get(svg);
|
|
69
|
+
if (known) return known;
|
|
70
|
+
const src = (svg.getAttribute('viewBox') ?? '') + svg.innerHTML.replace(/\s+/g, ' ');
|
|
71
|
+
let h = 5381;
|
|
72
|
+
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
|
|
73
|
+
const s = 'i' + (h >>> 0).toString(36);
|
|
74
|
+
sigs.set(svg, s);
|
|
75
|
+
return s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CLONE = 'data-lp-icon-clone';
|
|
79
|
+
const HIDDEN = 'data-lp-iconed';
|
|
80
|
+
/** The svgs the edit drew inside root (never our swapped-in copies, never tiny decorations). */
|
|
81
|
+
export function iconsIn(root: Element): SVGSVGElement[] {
|
|
82
|
+
return Array.from(root.querySelectorAll('svg')).filter((s) => !s.hasAttribute(CLONE) && !s.parentElement?.closest('svg') && s.getBoundingClientRect().width >= 10);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The colour an icon is painted in: its first visible fill or stroke, else its text colour. */
|
|
86
|
+
function paintOf(svg: SVGSVGElement): string {
|
|
87
|
+
for (const el of Array.from(svg.querySelectorAll('path, circle, rect, ellipse, line, polyline, polygon'))) {
|
|
88
|
+
const cs = getComputedStyle(el);
|
|
89
|
+
for (const v of [cs.fill, cs.stroke]) if (v && v !== 'none' && !v.startsWith('url') && !/rgba\([^)]*,\s*0\)/.test(v)) return v;
|
|
90
|
+
}
|
|
91
|
+
return getComputedStyle(svg).color;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function iconPass(root: Element, swaps: IconSwaps | undefined) {
|
|
95
|
+
const want = swaps ?? {};
|
|
96
|
+
for (const svg of Array.from(root.querySelectorAll<SVGSVGElement>(`svg:not([${CLONE}])`))) {
|
|
97
|
+
if (svg.parentElement?.closest('svg')) continue;
|
|
98
|
+
const pick = want[iconSig(svg)];
|
|
99
|
+
let clone = svg.nextElementSibling?.hasAttribute(CLONE) ? (svg.nextElementSibling as SVGSVGElement) : null;
|
|
100
|
+
if (!pick) {
|
|
101
|
+
if (svg.hasAttribute(HIDDEN)) svg.removeAttribute(HIDDEN);
|
|
102
|
+
clone?.remove();
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!clone) {
|
|
106
|
+
clone = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
107
|
+
clone.setAttribute(CLONE, '');
|
|
108
|
+
svg.after(clone);
|
|
109
|
+
}
|
|
110
|
+
if (clone.dataset.name !== pick.name) {
|
|
111
|
+
clone.dataset.name = pick.name;
|
|
112
|
+
clone.setAttribute('viewBox', `0 0 ${pick.w} ${pick.h}`);
|
|
113
|
+
clone.innerHTML = pick.body;
|
|
114
|
+
}
|
|
115
|
+
if (!svg.hasAttribute(HIDDEN)) svg.setAttribute(HIDDEN, '');
|
|
116
|
+
// its look: colour, glow, opacity, blend — the things that make it sit in the design
|
|
117
|
+
const cs = getComputedStyle(svg);
|
|
118
|
+
const st = clone.style;
|
|
119
|
+
st.color = paintOf(svg);
|
|
120
|
+
st.filter = cs.filter === 'none' ? '' : cs.filter;
|
|
121
|
+
st.opacity = cs.opacity;
|
|
122
|
+
st.mixBlendMode = cs.mixBlendMode;
|
|
123
|
+
st.pointerEvents = 'none';
|
|
124
|
+
st.overflow = 'visible';
|
|
125
|
+
// its place: measure where it lands at 0,0 and move it onto the original (works through any scaling around it)
|
|
126
|
+
const o = svg.getBoundingClientRect();
|
|
127
|
+
if (!o.width || !o.height) {
|
|
128
|
+
st.display = 'none';
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
st.display = '';
|
|
132
|
+
st.position = 'absolute';
|
|
133
|
+
st.left = '0px';
|
|
134
|
+
st.top = '0px';
|
|
135
|
+
st.margin = '0';
|
|
136
|
+
st.width = '100px';
|
|
137
|
+
st.height = '100px';
|
|
138
|
+
const c = clone.getBoundingClientRect();
|
|
139
|
+
const k = c.width / 100 || 1;
|
|
140
|
+
st.left = `${(o.left - c.left) / k}px`;
|
|
141
|
+
st.top = `${(o.top - c.top) / k}px`;
|
|
142
|
+
st.width = `${o.width / k}px`;
|
|
143
|
+
st.height = `${o.height / k}px`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Hides an icon that was swapped (its copy is drawn instead). Rendered once by the TweaksProvider. */
|
|
148
|
+
export const SWAP_CSS = `svg[${HIDDEN}] { visibility: hidden !important; }`;
|
|
149
|
+
|
|
150
|
+
// The preview's player is a separate bundle: it reads original families and icon signatures through the edit's copy,
|
|
151
|
+
// which remembers the originals.
|
|
152
|
+
if (typeof window !== 'undefined') (window as unknown as {__lpSwaps?: unknown}).__lpSwaps = {textFamilyIn, iconsIn, iconSig};
|
|
@@ -18,6 +18,8 @@ import {createPortal} from 'react-dom';
|
|
|
18
18
|
import {AbsoluteFill, Sequence, useCurrentFrame} from 'remotion';
|
|
19
19
|
import {followTransform, type TrackFile} from './track';
|
|
20
20
|
import {recolorTree, type Swap} from './recolor';
|
|
21
|
+
import {loadFontChoices} from './fontChoices';
|
|
22
|
+
import {iconPass, refontTree, SWAP_CSS, type FontSwaps, type IconSwaps} from './swaps';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* One beat's adjustments. All optional.
|
|
@@ -34,6 +36,14 @@ export type Tweak = {
|
|
|
34
36
|
/** colour swaps, {from: to}: each moves the whole shade family of `from` (src/lib/recolor.ts). Under the key
|
|
35
37
|
* ALL they apply to the whole video; on a <Part> they apply to that graphic first. */
|
|
36
38
|
recolor?: Swap;
|
|
39
|
+
/** font swaps, {from: to}: every text drawn in `from` is set in `to` (a brand family or one of
|
|
40
|
+
* src/lib/fontChoices.ts). On a graphic only inside it; under ALL in the whole video. */
|
|
41
|
+
fonts?: FontSwaps;
|
|
42
|
+
/** icons swapped inside a graphic: {<signature of the drawn icon>: the chosen icon} (src/lib/swaps.ts) */
|
|
43
|
+
icons?: IconSwaps;
|
|
44
|
+
/** under ALL: files the preview swapped, {old name: new name}. The preview shows it at once; Claude makes it real
|
|
45
|
+
* by changing the name in the edit's code (and copying an uploaded file in), then removes the entry. */
|
|
46
|
+
files?: Record<string, string>;
|
|
37
47
|
};
|
|
38
48
|
export type Tweaks = Record<string, Tweak>; // keyed by beat index, or a <Part>'s id
|
|
39
49
|
/** The tweaks key whose `recolor` applies to the whole video. */
|
|
@@ -49,6 +59,17 @@ const useRecolor = (swap: Swap | undefined, skipParts: boolean) => {
|
|
|
49
59
|
return ref;
|
|
50
60
|
};
|
|
51
61
|
|
|
62
|
+
/** Swapped icons inside an element, after every render (once any was set, so clearing them puts the originals back). */
|
|
63
|
+
const useIcons = (ref: React.RefObject<HTMLElement | null>, icons: IconSwaps | undefined) => {
|
|
64
|
+
const used = useRef(false);
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (icons && Object.keys(icons).length) used.current = true;
|
|
67
|
+
if (ref.current && used.current) iconPass(ref.current, icons);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
/** A graphic's own text colour, as one style rule scoped to it. */
|
|
71
|
+
const ownStyle = (sel: string, t: Tweak) => (t.color ? <style>{`${sel}, ${sel} * { color: ${t.color} !important; }`}</style> : null);
|
|
72
|
+
|
|
52
73
|
/** Where scale grows from, as a share of the frame. The preview's resize handle works from the same point. */
|
|
53
74
|
export const TWEAK_ORIGIN = '50% 55%';
|
|
54
75
|
export const TWEAK_LIMITS = {scale: [0.7, 1.4], dy: [-300, 300], shift: [-12, 12], gain: [-12, 6]} as const;
|
|
@@ -77,10 +98,28 @@ export const TweaksProvider: React.FC<{tweaks?: Tweaks; beatStarts: number[]; tr
|
|
|
77
98
|
const slotCtx = useMemo(() => ({slots, set}), [slots, set]);
|
|
78
99
|
// the whole video's colour swaps; a graphic with its own swaps does both itself (see Part)
|
|
79
100
|
const whole = useRecolor(tweaks[ALL]?.recolor, true);
|
|
101
|
+
// fonts chosen in the preview: loaded for the render (the preview carries its own copies)
|
|
102
|
+
const families = new Set<string>();
|
|
103
|
+
for (const t of Object.values(tweaks)) for (const f of Object.values(t.fonts ?? {})) families.add(f);
|
|
104
|
+
loadFontChoices(families);
|
|
105
|
+
const fontsUsed = useRef(false);
|
|
106
|
+
useLayoutEffect(() => {
|
|
107
|
+
if (families.size) fontsUsed.current = true;
|
|
108
|
+
if (!whole.current || !fontsUsed.current) return;
|
|
109
|
+
// a graphic's own swaps first, then the whole video's
|
|
110
|
+
refontTree(whole.current, (el) => {
|
|
111
|
+
const owner = el.closest<HTMLElement>('[data-lp-key], [data-lp-beat]');
|
|
112
|
+
const key = owner?.dataset.lpKey ?? owner?.dataset.lpBeat;
|
|
113
|
+
const own = key !== undefined ? tweaks[key]?.fonts : undefined;
|
|
114
|
+
const all = tweaks[ALL]?.fonts;
|
|
115
|
+
return own || all ? {...(all ?? {}), ...(own ?? {})} : undefined;
|
|
116
|
+
});
|
|
117
|
+
});
|
|
80
118
|
return (
|
|
81
119
|
<All.Provider value={{tweaks, beatAt, beatStarts, track, toSource}}>
|
|
82
120
|
<Slots.Provider value={slotCtx}>
|
|
83
121
|
<div ref={whole} data-lp-root="" style={{display: 'contents'}}>
|
|
122
|
+
<style>{SWAP_CSS}</style>
|
|
84
123
|
{children}
|
|
85
124
|
</div>
|
|
86
125
|
</Slots.Provider>
|
|
@@ -123,6 +162,7 @@ export const Part: React.FC<{id: string; children: React.ReactNode}> = ({id, chi
|
|
|
123
162
|
const swap = own || t.layer ? {...(all[ALL]?.recolor ?? {}), ...(own ?? {})} : undefined;
|
|
124
163
|
const order = own ? {...own, ...Object.fromEntries(Object.entries(all[ALL]?.recolor ?? {}).filter(([k]) => !(k in own)))} : swap;
|
|
125
164
|
const ref = useRecolor(order, false);
|
|
165
|
+
useIcons(ref, t.icons);
|
|
126
166
|
const placed = t.scale || t.dy || t.dx;
|
|
127
167
|
// display: contents with nothing applied — the layout is exactly as built. Always the same <div>, so a drag never
|
|
128
168
|
// rebuilds what is inside it.
|
|
@@ -132,7 +172,7 @@ export const Part: React.FC<{id: string; children: React.ReactNode}> = ({id, chi
|
|
|
132
172
|
if (t.hide) style.visibility = 'hidden';
|
|
133
173
|
const el = (
|
|
134
174
|
<div ref={ref} data-lp-key={id} data-lp-recolor={order ? '' : undefined} style={style}>
|
|
135
|
-
{
|
|
175
|
+
{ownStyle(`[data-lp-key="${id}"]`, t)}
|
|
136
176
|
{children}
|
|
137
177
|
</div>
|
|
138
178
|
);
|
|
@@ -170,10 +210,14 @@ export const Tweaked: React.FC<{move?: boolean; children: React.ReactNode}> = ({
|
|
|
170
210
|
// video. With nothing to apply it is display: contents — no box, so the layout is exactly as built. Both are the
|
|
171
211
|
// same <div>, so starting a drag never rebuilds what is inside it (the preview's selection would lose it).
|
|
172
212
|
const beat = ctx.beatAt(f);
|
|
213
|
+
const own = useRef<HTMLDivElement>(null);
|
|
214
|
+
useIcons(own, move ? t.icons : undefined);
|
|
173
215
|
const placed = (
|
|
174
216
|
<Here.Provider value={t}>
|
|
217
|
+
{move ? ownStyle(`[data-lp-beat="${beat}"]`, t) : null}
|
|
175
218
|
{move ? (
|
|
176
219
|
<div
|
|
220
|
+
ref={own}
|
|
177
221
|
data-lp-beat={beat}
|
|
178
222
|
style={
|
|
179
223
|
t.scale || t.dy || t.dx
|