creator-editing-studio 1.0.20260930

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 (69) hide show
  1. package/README.md +15 -0
  2. package/bin/init.mjs +72 -0
  3. package/package.json +18 -0
  4. package/template/CLAUDE.md +341 -0
  5. package/template/STYLE.md +165 -0
  6. package/template/package.json +45 -0
  7. package/template/remotion.config.ts +7 -0
  8. package/template/requirements.txt +17 -0
  9. package/template/scripts/align_script.py +106 -0
  10. package/template/scripts/assemble.mjs +364 -0
  11. package/template/scripts/beats.mjs +273 -0
  12. package/template/scripts/blur_regions.py +40 -0
  13. package/template/scripts/check_pair.py +90 -0
  14. package/template/scripts/doctor.mjs +141 -0
  15. package/template/scripts/find_cuts.py +45 -0
  16. package/template/scripts/grade.py +202 -0
  17. package/template/scripts/lib/media.mjs +228 -0
  18. package/template/scripts/lib/media.py +99 -0
  19. package/template/scripts/make_cutout.py +68 -0
  20. package/template/scripts/motion-check.mjs +268 -0
  21. package/template/scripts/motion-lint.mjs +122 -0
  22. package/template/scripts/new-video.mjs +49 -0
  23. package/template/scripts/prep.mjs +287 -0
  24. package/template/scripts/ramp.mjs +149 -0
  25. package/template/scripts/refs.mjs +72 -0
  26. package/template/scripts/refstyle.mjs +177 -0
  27. package/template/scripts/sounddesign.mjs +332 -0
  28. package/template/scripts/stills.mjs +64 -0
  29. package/template/scripts/sync-audio.mjs +134 -0
  30. package/template/scripts/transcribe.py +103 -0
  31. package/template/scripts/trim.mjs +374 -0
  32. package/template/src/Root.tsx +42 -0
  33. package/template/src/components/BrandBackground.tsx +22 -0
  34. package/template/src/components/Captions.tsx +141 -0
  35. package/template/src/components/Cursor.tsx +93 -0
  36. package/template/src/components/DrawnCircle.tsx +55 -0
  37. package/template/src/components/FilmOverlay.tsx +139 -0
  38. package/template/src/components/Hero.tsx +114 -0
  39. package/template/src/components/HeroTag.tsx +92 -0
  40. package/template/src/components/HighlightMark.tsx +34 -0
  41. package/template/src/components/LogoIcon.tsx +45 -0
  42. package/template/src/components/MacWindow.tsx +94 -0
  43. package/template/src/components/MaskReveal.tsx +34 -0
  44. package/template/src/components/MotionBlur.tsx +34 -0
  45. package/template/src/components/PlatformTile.tsx +153 -0
  46. package/template/src/components/Presence.tsx +27 -0
  47. package/template/src/components/RollingNumber.tsx +89 -0
  48. package/template/src/components/ScreenView.tsx +52 -0
  49. package/template/src/components/Stage.tsx +103 -0
  50. package/template/src/components/Transition.tsx +100 -0
  51. package/template/src/components/VelocityBlur.tsx +53 -0
  52. package/template/src/compositions/FilmTest.tsx +67 -0
  53. package/template/src/compositions/PresetTest.tsx +52 -0
  54. package/template/src/design/color.ts +6 -0
  55. package/template/src/design/glow.ts +11 -0
  56. package/template/src/design/logos.json +18 -0
  57. package/template/src/design/motion.ts +90 -0
  58. package/template/src/design/overlays.ts +121 -0
  59. package/template/src/design/presets.ts +65 -0
  60. package/template/src/design/tokens.ts +200 -0
  61. package/template/src/index.ts +4 -0
  62. package/template/src/lib/animate.ts +194 -0
  63. package/template/src/lib/camera.ts +58 -0
  64. package/template/src/lib/continuity.ts +86 -0
  65. package/template/src/lib/project.ts +13 -0
  66. package/template/src/lib/spine.ts +37 -0
  67. package/template/src/videos/sample/Sample.tsx +58 -0
  68. package/template/templates/BRIEF.md +88 -0
  69. package/template/tsconfig.json +17 -0
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Motion check — renders every frame of a composition and measures frame-to-frame
4
+ * change across a grid of tiles, to catch what reads as "not smooth" at playback:
5
+ *
6
+ * POP something appears or changes in place within a single frame (fails)
7
+ * CUT the whole frame changes at once (fails)
8
+ * STUTTER motion freezes for a frame, then continues (hitch / snapping) (fails)
9
+ * FAST an edge jumps more than a tile (120px) per frame — strobes (warning:
10
+ * add motion blur, slow it down, or feather the moving edge)
11
+ *
12
+ * Usage:
13
+ * node scripts/motion-check.mjs <CompositionId> [--frames=0-149] [--fps=30] [--scale=2] [--allow=45,90] [--keep]
14
+ *
15
+ * --scale render supersampled (e.g. 2) and downsample before measuring — match the final render
16
+ * --allow frame numbers where a cut is intentional
17
+ * --allow-file JSON array of such frames (e.g. from scripts/find_cuts.py); pops/stutters within
18
+ * 1 frame of them are also excused, since the footage itself jumps there
19
+ * --keep keep rendered PNGs in work/motion-check/<id>/ (deleted by default to save disk)
20
+ * --reuse analyse PNGs already kept from a previous run instead of rendering again
21
+ * Exit code 1 when any POP, CUT or STUTTER is found.
22
+ */
23
+ import {spawnSync} from 'node:child_process';
24
+ import {mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
25
+ import {join} from 'node:path';
26
+ import {PNG} from 'pngjs';
27
+
28
+ const args = process.argv.slice(2);
29
+ const comp = args.find((a) => !a.startsWith('--'));
30
+ const opt = (name) => args.find((a) => a.startsWith(`--${name}=`))?.split('=')[1];
31
+ if (!comp) {
32
+ console.error('Usage: node scripts/motion-check.mjs <CompositionId> [--frames=a-b] [--fps=30] [--scale=2] [--allow=f1,f2] [--keep]');
33
+ process.exit(2);
34
+ }
35
+ const fps = Number(opt('fps') ?? 30);
36
+ const scale = Number(opt('scale') ?? 1);
37
+ const allowed = new Set((opt('allow') ?? '').split(',').filter(Boolean).map(Number));
38
+ // --allow-file: JSON array of frames with intentional content cuts (e.g. jump cuts in the footage).
39
+ if (opt('allow-file')) for (const f of JSON.parse(readFileSync(opt('allow-file'), 'utf8'))) allowed.add(Number(f));
40
+ const nearAllowed = (frame) => allowed.has(frame) || allowed.has(frame - 1) || allowed.has(frame + 1);
41
+ const reuse = args.includes('--reuse');
42
+ const keep = args.includes('--keep') || reuse;
43
+
44
+ // Measured on the output-size image. Luma differences are 0-255 means over a tile.
45
+ const TILE = 120; // px
46
+ const STEP = 2; // sample every 2nd pixel in each direction
47
+ const STILL = 0.05; // below this a tile is not changing
48
+ const VISIBLE = 1.0; // clearly visible change
49
+ const POP_MIN = 3.0; // a pop must be at least this strong...
50
+ const POP_RATIO = 5; // ...and this many times stronger than both neighbouring frames
51
+ const CUT_MIN = 12; // whole-frame mean change that counts as a cut
52
+ const DETAIL = 3; // tile luma std-dev: below this the tile is flat, so a freeze there is invisible
53
+
54
+ const dir = join('work', 'motion-check', comp);
55
+ if (!reuse) {
56
+ rmSync(dir, {recursive: true, force: true});
57
+ mkdirSync(dir, {recursive: true});
58
+ }
59
+
60
+ const renderArgs = ['remotion', 'render', comp, `"${dir}"`, '--sequence', '--image-format=png', '--log=error'];
61
+ if (opt('frames')) renderArgs.push(`--frames=${opt('frames')}`);
62
+ if (scale !== 1) renderArgs.push(`--scale=${scale}`);
63
+ if (!reuse) {
64
+ console.log(`Rendering every frame of ${comp}${scale !== 1 ? ` at ${scale}x` : ''}...`);
65
+ const render = spawnSync('npx', renderArgs, {stdio: 'inherit', shell: true});
66
+ if (render.status !== 0) {
67
+ console.error('Render failed.');
68
+ process.exit(2);
69
+ }
70
+ }
71
+
72
+ const files = readdirSync(dir)
73
+ .filter((f) => f.endsWith('.png'))
74
+ .map((f) => ({f, n: Number(f.match(/(\d+)\.png$/)[1])}))
75
+ .sort((a, b) => a.n - b.n);
76
+
77
+ /** Luma at output size; supersampled renders are box-downsampled like the final encode. */
78
+ const toLuma = (png) => {
79
+ const w = Math.floor(png.width / scale);
80
+ const h = Math.floor(png.height / scale);
81
+ const luma = new Float32Array(w * h);
82
+ const area = scale * scale;
83
+ for (let y = 0; y < h; y++) {
84
+ for (let x = 0; x < w; x++) {
85
+ let sum = 0;
86
+ for (let dy = 0; dy < scale; dy++) {
87
+ for (let dx = 0; dx < scale; dx++) {
88
+ const i = ((y * scale + dy) * png.width + (x * scale + dx)) * 4;
89
+ sum += 0.299 * png.data[i] + 0.587 * png.data[i + 1] + 0.114 * png.data[i + 2];
90
+ }
91
+ }
92
+ luma[y * w + x] = sum / area;
93
+ }
94
+ }
95
+ return {luma, w, h};
96
+ };
97
+
98
+ let prev = null;
99
+ let width = 0;
100
+ let height = 0;
101
+ let cols = 0;
102
+ let rows = 0;
103
+ const series = []; // one entry per frame transition, keyed by the frame that appears
104
+
105
+ for (const {f, n} of files) {
106
+ const cur = toLuma(PNG.sync.read(readFileSync(join(dir, f))));
107
+ if (!prev) {
108
+ ({w: width, h: height} = cur);
109
+ cols = Math.ceil(width / TILE);
110
+ rows = Math.ceil(height / TILE);
111
+ } else {
112
+ const sums = new Float64Array(cols * rows);
113
+ const counts = new Uint32Array(cols * rows);
114
+ const lumaSum = new Float64Array(cols * rows);
115
+ const lumaSq = new Float64Array(cols * rows);
116
+ for (let y = 0; y < height; y += STEP) {
117
+ const rowBase = Math.floor(y / TILE) * cols;
118
+ for (let x = 0; x < width; x += STEP) {
119
+ const i = y * width + x;
120
+ const t = rowBase + Math.floor(x / TILE);
121
+ sums[t] += Math.abs(cur.luma[i] - prev.luma[i]);
122
+ lumaSum[t] += cur.luma[i];
123
+ lumaSq[t] += cur.luma[i] * cur.luma[i];
124
+ counts[t]++;
125
+ }
126
+ }
127
+ const tiles = new Float32Array(cols * rows);
128
+ const detail = new Float32Array(cols * rows);
129
+ let total = 0;
130
+ let count = 0;
131
+ for (let t = 0; t < tiles.length; t++) {
132
+ tiles[t] = sums[t] / counts[t];
133
+ const mean = lumaSum[t] / counts[t];
134
+ detail[t] = Math.sqrt(Math.max(0, lumaSq[t] / counts[t] - mean * mean));
135
+ total += sums[t];
136
+ count += counts[t];
137
+ }
138
+ series.push({frame: n, tiles, detail, global: total / count});
139
+ }
140
+ prev = cur;
141
+ }
142
+
143
+ const region = (tileList) => {
144
+ const xs = tileList.map((t) => t % cols);
145
+ const ys = tileList.map((t) => Math.floor(t / cols));
146
+ const x0 = Math.min(...xs) * TILE;
147
+ const x1 = Math.min((Math.max(...xs) + 1) * TILE, width);
148
+ const y0 = Math.min(...ys) * TILE;
149
+ const y1 = Math.min((Math.max(...ys) + 1) * TILE, height);
150
+ return `x ${x0}-${x1}, y ${y0}-${y1}`;
151
+ };
152
+ const time = (frame) => `${(frame / fps).toFixed(2)}s`;
153
+
154
+ const neighbours = (t) => {
155
+ const cx = t % cols;
156
+ const cy = Math.floor(t / cols);
157
+ const list = [];
158
+ for (let dy = -1; dy <= 1; dy++) {
159
+ for (let dx = -1; dx <= 1; dx++) {
160
+ const x = cx + dx;
161
+ const y = cy + dy;
162
+ if ((dx || dy) && x >= 0 && x < cols && y >= 0 && y < rows) list.push(y * cols + x);
163
+ }
164
+ }
165
+ return list;
166
+ };
167
+
168
+ const issues = [];
169
+ const warnings = [];
170
+ series.forEach((cur, k) => {
171
+ const before = series[k - 1];
172
+ const after = series[k + 1];
173
+
174
+ const neighbourGlobal = Math.max(before?.global ?? 0, after?.global ?? 0, STILL);
175
+ if (cur.global >= CUT_MIN && cur.global >= POP_RATIO * neighbourGlobal) {
176
+ if (!allowed.has(cur.frame)) {
177
+ issues.push({type: 'CUT', frame: cur.frame, detail: `whole frame changes at once (mean change ${cur.global.toFixed(1)})`});
178
+ }
179
+ return;
180
+ }
181
+
182
+ const pops = [];
183
+ const fast = [];
184
+ const stutters = [];
185
+ for (let t = 0; t < cur.tiles.length; t++) {
186
+ const v = cur.tiles[t];
187
+ const vb = before?.tiles[t] ?? 0;
188
+ const va = after?.tiles[t] ?? 0;
189
+ if (v >= POP_MIN && v >= POP_RATIO * Math.max(vb, va, STILL)) {
190
+ // Change that continues in an adjacent tile on the next/previous frame is a travelling edge, not a pop.
191
+ const travelling = neighbours(t).some(
192
+ (nb) => (before?.tiles[nb] ?? 0) >= VISIBLE || (after?.tiles[nb] ?? 0) >= VISIBLE,
193
+ );
194
+ (travelling ? fast : pops).push(t);
195
+ }
196
+ // A freeze is only visible where the tile has detail (flat card interiors legitimately don't change).
197
+ if (before && after && v <= STILL && vb >= VISIBLE && va >= VISIBLE && cur.detail[t] >= DETAIL) stutters.push(t);
198
+ }
199
+ if (nearAllowed(cur.frame)) return;
200
+ if (pops.length) {
201
+ const peak = Math.max(...pops.map((t) => cur.tiles[t]));
202
+ issues.push({type: 'POP', frame: cur.frame, detail: `appears/changes in one frame at ${region(pops)} (strength ${peak.toFixed(1)})`});
203
+ }
204
+ if (stutters.length) {
205
+ issues.push({type: 'STUTTER', frame: cur.frame, detail: `motion freezes for a frame at ${region(stutters)}`});
206
+ }
207
+ if (fast.length) {
208
+ warnings.push({type: 'FAST', frame: cur.frame, detail: `edge jumps >${TILE}px in one frame at ${region(fast)}`});
209
+ }
210
+ });
211
+
212
+ // Motion timeline: how much the frame changes over time (ASCII, 60 frames per row).
213
+ const LEVELS = ' .:-=+*#%@';
214
+ const peak = Math.max(...series.map((s) => s.global), STILL);
215
+ console.log(`\nMotion timeline (${files.length} frames @ ${fps}fps, one character per frame, '@' = most change):`);
216
+ for (let i = 0; i < series.length; i += 60) {
217
+ const row = series
218
+ .slice(i, i + 60)
219
+ .map((s) => (s.global <= STILL ? ' ' : LEVELS[Math.max(1, Math.round((s.global / peak) * (LEVELS.length - 1)))]))
220
+ .join('');
221
+ console.log(` ${String(series[i].frame).padStart(4)} |${row}|`);
222
+ }
223
+
224
+ let longestStill = {start: 0, length: 0};
225
+ let run = null;
226
+ for (const s of series) {
227
+ if (s.global <= STILL) {
228
+ run = run ? {start: run.start, length: run.length + 1} : {start: s.frame, length: 1};
229
+ if (run.length > longestStill.length) longestStill = run;
230
+ } else {
231
+ run = null;
232
+ }
233
+ }
234
+ if (longestStill.length > 0) {
235
+ console.log(
236
+ ` Longest fully static stretch: frames ${longestStill.start}-${longestStill.start + longestStill.length - 1} (${(longestStill.length / fps).toFixed(2)}s) — fine for a reading hold; make sure it isn't dead air.`,
237
+ );
238
+ }
239
+
240
+ console.log('');
241
+ for (const item of [...issues, ...warnings].sort((a, b) => a.frame - b.frame)) {
242
+ console.log(`${item.type.padEnd(8)} frame ${String(item.frame).padStart(4)} (${time(item.frame)}) ${item.detail}`);
243
+ }
244
+ console.log(
245
+ `motion-check: ${issues.length === 0 ? 'no pops, cuts or stutters' : `${issues.length} issue(s)`}, ${warnings.length} fast-motion warning(s)`,
246
+ );
247
+
248
+ writeFileSync(
249
+ join('work', 'motion-check', `${comp}.report.json`),
250
+ JSON.stringify(
251
+ {
252
+ composition: comp,
253
+ fps,
254
+ scale,
255
+ frames: files.length,
256
+ width,
257
+ height,
258
+ issues,
259
+ warnings,
260
+ longestStill,
261
+ globalChange: series.map((s) => ({frame: s.frame, change: Number(s.global.toFixed(3))})),
262
+ },
263
+ null,
264
+ 2,
265
+ ),
266
+ );
267
+ if (!keep) rmSync(dir, {recursive: true, force: true});
268
+ process.exit(issues.length > 0 ? 1 : 0);
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Motion lint — enforces the motion laws in CLAUDE.md.
4
+ * Catches patterns, not taste: passing lint is required, not sufficient.
5
+ * Suppress a deliberate exception with a `// motion-ok: <reason>` comment
6
+ * on the same line or the line above.
7
+ * Exit code 1 when any error is found.
8
+ */
9
+ import {readFileSync, readdirSync, statSync} from 'node:fs';
10
+ import {join, relative, sep} from 'node:path';
11
+
12
+ const ROOT = process.cwd();
13
+ const TOKENS_FILE = 'src/design/motion.ts';
14
+ const DESIGN_DIR = 'src/design/';
15
+
16
+ const walk = (dir) =>
17
+ readdirSync(dir).flatMap((name) => {
18
+ const p = join(dir, name);
19
+ if (statSync(p).isDirectory()) return walk(p);
20
+ return /\.(ts|tsx)$/.test(name) ? [p] : [];
21
+ });
22
+
23
+ const findings = [];
24
+ const report = (level, file, line, rule, msg) => findings.push({level, file, line, rule, msg});
25
+
26
+ const suppressed = (lines, ln) => /motion-ok/.test(lines[ln - 1] ?? '') || /motion-ok/.test(lines[ln - 2] ?? '');
27
+
28
+ const balancedCall = (text, openIdx) => {
29
+ let depth = 0;
30
+ for (let i = openIdx; i < text.length; i++) {
31
+ if (text[i] === '(') depth++;
32
+ else if (text[i] === ')' && --depth === 0) return text.slice(openIdx, i + 1);
33
+ }
34
+ return text.slice(openIdx);
35
+ };
36
+
37
+ for (const abs of walk(join(ROOT, 'src'))) {
38
+ const file = relative(ROOT, abs).split(sep).join('/');
39
+ const text = readFileSync(abs, 'utf8');
40
+ const lines = text.split('\n');
41
+ const isTokens = file === TOKENS_FILE;
42
+ const isDesign = file.startsWith(DESIGN_DIR);
43
+ const isScene = /^src\/(components|compositions)\//.test(file);
44
+
45
+ lines.forEach((raw, i) => {
46
+ const ln = i + 1;
47
+ if (/^\s*(\*|\/\*|\/\/)/.test(raw) || suppressed(lines, ln)) return;
48
+ const code = raw.replace(/\s\/\/.*$/, '');
49
+
50
+ if (/Easing\.linear\b/.test(code)) {
51
+ report('error', file, ln, 'no-linear', 'Linear easing is banned (law 1). Use EASE tokens.');
52
+ }
53
+ if (!isTokens && /Easing\.bezier\s*\(|cubic-bezier\s*\(/.test(code)) {
54
+ report('error', file, ln, 'curves-from-tokens', 'Define curves only in src/design/motion.ts and use EASE.*');
55
+ }
56
+ if (!isTokens && /\b(damping|stiffness|mass)\s*:/.test(code)) {
57
+ report('error', file, ln, 'springs-from-tokens', 'Spring configs live in SPRING tokens. Use settle() or SPRING.*');
58
+ }
59
+ if (/\b(transition|animation|transitionDuration|animationName)\s*:/.test(code)) {
60
+ report('error', file, ln, 'no-css-animation', 'CSS transitions/animations do not render frame-accurately in Remotion. Drive everything from useCurrentFrame().');
61
+ }
62
+ if (/\bwillChange\s*:|will-change/.test(code)) {
63
+ report('error', file, ln, 'no-will-change', 'will-change makes each render tab cache layers differently, so settled elements flicker between frames. Supersample the render (--scale=2) instead.');
64
+ }
65
+ if (/Math\.random\s*\(/.test(code)) {
66
+ report('error', file, ln, 'no-math-random', 'Math.random() renders differently every frame. Use random(seed) from remotion.');
67
+ }
68
+ for (const m of code.matchAll(/scale\(\s*(\d*\.?\d+)\s*\)/g)) {
69
+ if (Number(m[1]) < 0.9) {
70
+ report('error', file, ln, 'no-scale-from-zero', `scale(${m[1]}) — never scale below 0.9 (law 6).`);
71
+ }
72
+ }
73
+ for (const m of code.matchAll(/scaleFrom\s*:\s*(\d*\.?\d+)/g)) {
74
+ if (Number(m[1]) < 0.9) {
75
+ report('warn', file, ln, 'scale-floor', `scaleFrom ${m[1]} will be clamped to 0.9 (law 6).`);
76
+ }
77
+ }
78
+ for (const m of code.matchAll(/blur\(\s*(\d*\.?\d+)px/g)) {
79
+ if (Number(m[1]) > 12) {
80
+ report('warn', file, ln, 'blur-cap', `blur(${m[1]}px) exceeds the 12px cap (law 7). Fine only for static depth-of-field plates.`);
81
+ }
82
+ }
83
+ if (isScene && /\b(durationInFrames|from)\s*=\s*\{\s*[1-9]\d*\s*\}/.test(code)) {
84
+ report('warn', file, ln, 'magic-timing', 'Hard-coded frame number. Derive timing from the spine (words/beats) or token helpers.');
85
+ }
86
+ if (isScene && /\b(at|until)\s*:\s*[1-9]\d*\b/.test(code)) {
87
+ report('warn', file, ln, 'magic-timing', 'Hard-coded frame number. Derive timing from the spine (words/beats) or token helpers.');
88
+ }
89
+ if (!isDesign && /['"`][^'"`]*(#[0-9a-fA-F]{3,8}\b|rgba?\()/.test(code)) {
90
+ report('warn', file, ln, 'raw-color', 'Raw colour value. Use COLOR tokens from src/design/tokens.ts.');
91
+ }
92
+ if (!isDesign && /fontFamily\s*:\s*['"`]/.test(code)) {
93
+ report('warn', file, ln, 'raw-font', 'Raw font family. Use FONT tokens from src/design/tokens.ts.');
94
+ }
95
+ });
96
+
97
+ for (const m of text.matchAll(/\binterpolate\s*\(/g)) {
98
+ const ln = text.slice(0, m.index).split('\n').length;
99
+ if (suppressed(lines, ln)) continue;
100
+ const call = balancedCall(text, m.index + m[0].length - 1);
101
+ if (!/\beasing\b/.test(call)) {
102
+ report('error', file, ln, 'interpolate-needs-easing', 'interpolate() without easing is linear motion (law 1). Use EASE.*, a helper from src/lib/animate.ts, or mark a pure value mapping with // motion-ok.');
103
+ }
104
+ const lineStart = text.lastIndexOf('\n', m.index) + 1;
105
+ if (/scale\s*[:=]\s*$/i.test(text.slice(lineStart, m.index))) {
106
+ const outputRange = [...call.matchAll(/\[([^[\]]*)\]/g)][1]?.[1];
107
+ const first = outputRange?.split(',')[0]?.trim();
108
+ if (first !== undefined && first !== '' && Number(first) < 0.9) {
109
+ report('error', file, ln, 'no-scale-from-zero', `Scale animates from ${first} — never below 0.9 (law 6).`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ const errors = findings.filter((f) => f.level === 'error');
116
+ const warns = findings.filter((f) => f.level === 'warn');
117
+
118
+ for (const f of [...errors, ...warns]) {
119
+ console.log(`${f.level === 'error' ? 'ERROR' : 'warn '} ${f.file}:${f.line} [${f.rule}] ${f.msg}`);
120
+ }
121
+ console.log(`\nmotion-lint: ${errors.length} error(s), ${warns.length} warning(s)`);
122
+ process.exit(errors.length > 0 ? 1 : 0);
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Start a new video project with its own folders, so assets from different videos never mix.
4
+ *
5
+ * npm run new-video -- <name> e.g. npm run new-video -- founder-story
6
+ *
7
+ * Creates:
8
+ * assets/projects/<name>/BRIEF.md the brief (from templates/BRIEF.md) — script, moments, references
9
+ * assets/projects/<name>/footage/ talking-head clip (+ "<name>-GS.mp4" green twin), b-roll clips
10
+ * assets/projects/<name>/screenshots/ raw screenshots (+ blur-regions.json if names need blurring)
11
+ * assets/projects/<name>/processed/ generated: blurred screenshots, trimmed b-roll
12
+ * assets/projects/<name>/cutouts/ generated: RGBA cutout frames for text-behind-subject
13
+ * assets/projects/<name>/audio/ optional: music / sfx if you want them in the render
14
+ * src/videos/<name>/ code; script.txt is filled from the brief by prep
15
+ * work/<name>/ transcripts, previews, logs (disposable)
16
+ * out/<name>/ final renders + sound cue lists
17
+ */
18
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
19
+ import {join} from 'node:path';
20
+
21
+ const readmeFor = (n) => "# @@\n\nDrop this video's files here. Nothing in this folder is used by any other video.\n\n1. Fill in `BRIEF.md` (the script goes in it too).\n2. Put files in the folders below.\n3. Run `npm run prep -- @@`, then start the session with \"start @@\".\n\n| Folder | Put here |\n|---|---|\n| `footage/` | Your talking-head clip, straight off the phone (any name). For text behind you, also its green-screen copy named `<same name>-GS.mp4`. B-roll clips. |\n| `screenshots/` | Screenshots to show (PNG/WebP). List what must be blurred in the brief. |\n| `audio/` | Your sound-effects library for this video, if any. |\n| `processed/`, `cutouts/` | Generated — leave empty. |\n".split('@@').join(n);
22
+
23
+ const name = process.argv[2];
24
+ if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
25
+ console.error('Usage: npm run new-video -- <name> (lowercase letters, digits and dashes, e.g. founder-story)');
26
+ process.exit(2);
27
+ }
28
+ const code = join('src', 'videos', name);
29
+ if (existsSync(code)) {
30
+ console.error(`A video called "${name}" already exists (${code}). Pick another name.`);
31
+ process.exit(1);
32
+ }
33
+
34
+ const assets = join('assets', 'projects', name);
35
+ for (const dir of ['footage', 'screenshots', 'processed', 'cutouts', 'audio']) mkdirSync(join(assets, dir), {recursive: true});
36
+ for (const dir of [code, join('work', name), join('out', name)]) mkdirSync(dir, {recursive: true});
37
+
38
+ writeFileSync(join(code, 'script.txt'), '');
39
+ // The brief lives next to the files the user drops in; `npm run prep` reads it.
40
+ writeFileSync(join(assets, 'BRIEF.md'), readFileSync(join('templates', 'BRIEF.md'), 'utf8').split('{{name}}').join(name));
41
+ writeFileSync(join(assets, 'README.md'), readmeFor(name));
42
+
43
+ console.log(`Created video project "${name}":
44
+ ${assets}\\BRIEF.md <- fill this in (script goes in it too)
45
+ ${assets}\\{footage,screenshots,processed,cutouts,audio}
46
+ work\\${name}\\ out\\${name}\\
47
+
48
+ Next: fill in BRIEF.md, drop footage + screenshots into ${assets},
49
+ run "npm run prep -- ${name}", then start the session with "start ${name}".`);