luckiest-co 1.0.30 → 1.0.32
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/luckiest-brandkit/CHANGELOG.md +18 -0
- package/skills/luckiest-brandkit/SKILL.md +95 -59
- package/skills/luckiest-brandkit/references/picks-and-review.md +1 -1
- package/skills/luckiest-brandkit/scripts/build_review.mjs +3 -2
- package/skills/luckiest-brandkit/scripts/build_review.test.mjs +11 -0
- package/skills/luckiest-brandkit/scripts/contact_sheet.py +3 -2
- package/skills/luckiest-brandkit-sizing/CHANGELOG.md +13 -0
- package/skills/luckiest-brandkit-sizing/SKILL.md +126 -0
- package/skills/luckiest-brandkit-sizing/references/signage-and-print.md +60 -0
- package/skills/luckiest-video-studio/ATTRIBUTION.md +2 -1
- package/skills/luckiest-video-studio/CHANGELOG.md +10 -0
- package/skills/luckiest-video-studio/SECURITY.md +5 -2
- package/skills/luckiest-video-studio/SKILL.md +39 -27
- package/skills/luckiest-video-studio/references/concat.md +3 -2
- package/skills/luckiest-video-studio-cuts/ATTRIBUTION.md +5 -4
- package/skills/luckiest-video-studio-cuts/CHANGELOG.md +6 -0
- package/skills/luckiest-video-studio-cuts/SKILL.md +19 -11
- package/skills/luckiest-video-studio-cuts/scripts/build-cuts-reel.mjs +63 -0
- package/skills/luckiest-video-studio-cuts/scripts/build-cuts-reel.test.mjs +35 -0
- package/skills/luckiest-video-studio-review/ATTRIBUTION.md +50 -0
- package/skills/luckiest-video-studio-review/CHANGELOG.md +44 -0
- package/skills/luckiest-video-studio-review/LICENSE-motion-os +21 -0
- package/skills/luckiest-video-studio-review/SKILL.md +125 -0
- package/skills/luckiest-video-studio-review/editor/index.html +885 -0
- package/skills/luckiest-video-studio-review/editor/serve.mjs +111 -0
- package/skills/luckiest-video-studio-review/references/apply-feedback.md +71 -0
- package/skills/luckiest-video-studio-review/scripts/build-reel.mjs +96 -0
- package/skills/luckiest-video-studio-review/scripts/build-reel.test.mjs +62 -0
- package/skills/luckiest-video-studio-cuts/scripts/build-edl-review.mjs +0 -114
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Motion OS player server. Serves the player page plus one project folder (reel.json, the video, assets/)
|
|
3
|
+
// on localhost and opens it in the browser. No dependencies.
|
|
4
|
+
// node serve.mjs <project-dir> [--port 4321] [--no-open]
|
|
5
|
+
// node serve.mjs --selftest
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import {fileURLToPath} from 'node:url';
|
|
11
|
+
import {exec, spawn} from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const TYPES = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8', '.json': 'application/json', '.js': 'text/javascript', '.css': 'text/css',
|
|
15
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
|
16
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
17
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// "bytes=0-99" / "bytes=100-" / "bytes=-100" -> [start, end] inclusive, or null if unusable.
|
|
21
|
+
export function parseRange(header, size) {
|
|
22
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header || '');
|
|
23
|
+
if (!m || (m[1] === '' && m[2] === '')) return null;
|
|
24
|
+
const start = m[1] === '' ? Math.max(0, size - Number(m[2])) : Number(m[1]);
|
|
25
|
+
const end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
|
|
26
|
+
return start <= end && start < size ? [start, end] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.argv[2] === '--selftest') {
|
|
30
|
+
const eq = (a, b) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
|
|
31
|
+
eq(parseRange('bytes=0-99', 1000), [0, 99]);
|
|
32
|
+
eq(parseRange('bytes=900-', 1000), [900, 999]);
|
|
33
|
+
eq(parseRange('bytes=-100', 1000), [900, 999]);
|
|
34
|
+
eq(parseRange('bytes=0-5000', 1000), [0, 999]);
|
|
35
|
+
eq(parseRange('bytes=1000-', 1000), null);
|
|
36
|
+
eq(parseRange('bytes=-', 1000), null);
|
|
37
|
+
eq(parseRange(undefined, 1000), null);
|
|
38
|
+
console.log('selftest ok');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const flag = (name) => { const i = args.indexOf(name); return i < 0 ? null : args.splice(i, name === '--no-open' ? 1 : 2)[1] ?? true; };
|
|
44
|
+
const noOpen = flag('--no-open');
|
|
45
|
+
let port = Number(flag('--port') || process.env.PORT || 4321);
|
|
46
|
+
const dir = path.resolve(args[0] || '.');
|
|
47
|
+
const page = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
|
|
48
|
+
if (!fs.existsSync(path.join(dir, 'reel.json'))) { console.error(`Motion OS: no reel.json in ${dir}`); process.exit(1); }
|
|
49
|
+
|
|
50
|
+
// Export: reel.json "export": {"cwd": build folder, "cmd": shell command}. The command gets $PROPS (a JSON file with the
|
|
51
|
+
// user's edits), $RAW (temp render path), $OUT (final mp4 in <project>/exports/) and $PROJECT. One export at a time.
|
|
52
|
+
let job = {state: 'idle'};
|
|
53
|
+
function startExport(props) {
|
|
54
|
+
const reel = JSON.parse(fs.readFileSync(path.join(dir, 'reel.json'), 'utf8')), ex = reel.export;
|
|
55
|
+
if (!ex) return {state: 'error', line: 'This project has no "export" in reel.json.'};
|
|
56
|
+
if (job.state === 'running') return job;
|
|
57
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
|
|
58
|
+
fs.mkdirSync(path.join(dir, 'exports'), {recursive: true});
|
|
59
|
+
const out = path.join(dir, 'exports', `${reel.id}-${stamp}.mp4`), tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'motionos-'));
|
|
60
|
+
const env = {...process.env, PROJECT: dir, OUT: out, RAW: path.join(tmp, 'raw.mp4'), PROPS: path.join(tmp, 'props.json')};
|
|
61
|
+
fs.writeFileSync(env.PROPS, JSON.stringify({...ex.props, ...props}));
|
|
62
|
+
job = {state: 'running', pct: 0, line: 'Starting', out: path.relative(dir, out)};
|
|
63
|
+
const p = spawn('sh', ['-c', ex.cmd], {cwd: ex.cwd || dir, env});
|
|
64
|
+
const onData = (d) => {
|
|
65
|
+
const text = String(d), m = [...text.matchAll(/(\d+)\/(\d+)/g)].pop();
|
|
66
|
+
if (m && +m[2] > 0) job.pct = Math.min(99, Math.round(+m[1] / +m[2] * 100));
|
|
67
|
+
const last = text.trim().split(/[\r\n]+/).pop(); if (last) job.line = last.slice(0, 200);
|
|
68
|
+
};
|
|
69
|
+
p.stdout.on('data', onData); p.stderr.on('data', onData);
|
|
70
|
+
p.on('close', (code) => { job = code === 0 ? {...job, state: 'done', pct: 100} : {...job, state: 'error'}; fs.rmSync(tmp, {recursive: true, force: true}); });
|
|
71
|
+
return job;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const server = http.createServer((req, res) => {
|
|
75
|
+
const url = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
76
|
+
if (url === '/export') {
|
|
77
|
+
// Only our own page may start a render (blocks other websites posting to localhost).
|
|
78
|
+
if (req.method === 'POST' && req.headers.origin && !/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(req.headers.origin)) return res.writeHead(403).end();
|
|
79
|
+
const send = (j) => res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify(j));
|
|
80
|
+
if (req.method !== 'POST') return send(job);
|
|
81
|
+
let body = ''; req.on('data', (c) => body += c);
|
|
82
|
+
return req.on('end', () => { try { send(startExport(JSON.parse(body || '{}'))); } catch (e) { send({state: 'error', line: e.message}); } });
|
|
83
|
+
}
|
|
84
|
+
let file = url === '/' ? page : path.resolve(dir, '.' + url);
|
|
85
|
+
if (file !== page && !file.startsWith(dir + path.sep)) return res.writeHead(403).end('Forbidden');
|
|
86
|
+
// Remotion's staticFile() paths point at the site root; serve them from public/.
|
|
87
|
+
if (file !== page && !fs.existsSync(file) && fs.existsSync(path.join(dir, 'public', '.' + url))) file = path.join(dir, 'public', '.' + url);
|
|
88
|
+
fs.stat(file, (err, stat) => {
|
|
89
|
+
if (err || !stat.isFile()) return res.writeHead(404).end('Not found');
|
|
90
|
+
const head = {'Content-Type': TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store'};
|
|
91
|
+
if (req.headers.range) { // video seeking needs byte ranges
|
|
92
|
+
const r = parseRange(req.headers.range, stat.size);
|
|
93
|
+
if (!r) return res.writeHead(416, {'Content-Range': `bytes */${stat.size}`}).end();
|
|
94
|
+
res.writeHead(206, {...head, 'Content-Range': `bytes ${r[0]}-${r[1]}/${stat.size}`, 'Content-Length': r[1] - r[0] + 1});
|
|
95
|
+
return req.method === 'HEAD' ? res.end() : fs.createReadStream(file, {start: r[0], end: r[1]}).pipe(res);
|
|
96
|
+
}
|
|
97
|
+
res.writeHead(200, {...head, 'Content-Length': stat.size});
|
|
98
|
+
req.method === 'HEAD' ? res.end() : fs.createReadStream(file).pipe(res);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.on('error', (e) => {
|
|
103
|
+
if (e.code !== 'EADDRINUSE') throw e;
|
|
104
|
+
port += 1; server.listen(port, '127.0.0.1'); // try the next port
|
|
105
|
+
});
|
|
106
|
+
server.on('listening', () => {
|
|
107
|
+
const url = `http://localhost:${port}`;
|
|
108
|
+
console.log(`Motion OS: ${url} (project: ${dir}) Ctrl+C to stop`);
|
|
109
|
+
if (!noOpen) exec(`${process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open'} ${url}`);
|
|
110
|
+
});
|
|
111
|
+
server.listen(port, '127.0.0.1');
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Applying a review player prompt
|
|
2
|
+
|
|
3
|
+
A pasted prompt starts with `Motion OS feedback for "<title>" v<N>` (the player
|
|
4
|
+
is Motion OS underneath) and has up to four parts: Edits, Notes, Links, and
|
|
5
|
+
Scenes.
|
|
6
|
+
|
|
7
|
+
## Before anything
|
|
8
|
+
|
|
9
|
+
- **Version.** Compare `v<N>` with `version` in `<run-dir>/reel.json`. If the
|
|
10
|
+
prompt is older, say so in one line: the user reviewed an earlier render, so
|
|
11
|
+
check every edit's "before" value (below) instead of trusting its timing.
|
|
12
|
+
- **Run.** The `Project:` line is the run folder. Work only there.
|
|
13
|
+
|
|
14
|
+
## Edits
|
|
15
|
+
|
|
16
|
+
Each line reads:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
- [00:01.2 · hook · Your invoices, done] Headline (storyboard.json scenes[0] · composition/compositions/hook.html): "Invoices, done" -> "Invoices, handled"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Field label | Change |
|
|
23
|
+
|---|---|
|
|
24
|
+
| A variable's label (Headline, Value, ...) | `scenes[N].variables.<key>` to the new value exactly. The label is the key with its first letter capitalized and hyphens shown as spaces (`cta-text` shows as `Cta text`). |
|
|
25
|
+
| Footage | `scenes[N].source` |
|
|
26
|
+
| Timing (`[start, end]`) | the scene's `duration` (end minus start). Only for the scene named; later scenes shift on their own. |
|
|
27
|
+
| `Style · <color name>` (`#old -> #new`) | every variable holding `#old`, in every scene that is not approved |
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
|
|
31
|
+
- Apply an edit only when the storyboard's current value equals the "before"
|
|
32
|
+
value. If it does not, skip it and list it as changed since that render.
|
|
33
|
+
- An edit naming a scene id that is no longer in `storyboard.json` is skipped and
|
|
34
|
+
reported, never guessed onto another scene.
|
|
35
|
+
- If the composition hardcodes the text instead of reading `variables`, move the
|
|
36
|
+
text into `variables` first, then edit it.
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
Each note has a timestamp, a scene, an x/y position in percent of the frame, and
|
|
41
|
+
the element it sits on. Notes starting with "Claude:" are your own pre-pinned
|
|
42
|
+
notes the user kept: treat them as agreed.
|
|
43
|
+
|
|
44
|
+
1. Grab the frame and look at it before deciding what the note means:
|
|
45
|
+
`ffmpeg -v error -ss <t> -i <run-dir>/final.mp4 -frames:v 1 <run-dir>/qa/note-<i>.jpg`
|
|
46
|
+
2. Motion, pacing, and placement notes change the scene's composition or its
|
|
47
|
+
`duration`.
|
|
48
|
+
3. A note on a demo scene about the footage itself (a typo in the product, a
|
|
49
|
+
cursor jump) cannot be fixed by re-rendering. Say so, and offer a new
|
|
50
|
+
recording or a trim through `sourceStart` and `duration`. For talking-head
|
|
51
|
+
footage, hand it to `luckiest-video-studio-cuts`.
|
|
52
|
+
|
|
53
|
+
## Links
|
|
54
|
+
|
|
55
|
+
Download links into `assets/` only when they are clearly the user's own files.
|
|
56
|
+
Ask first otherwise.
|
|
57
|
+
|
|
58
|
+
## Scenes
|
|
59
|
+
|
|
60
|
+
The last block lists each scene as `in review`, `needs changes`, or `approved`.
|
|
61
|
+
Never touch an approved scene, even when an edit or note seems to point at it;
|
|
62
|
+
list those as skipped.
|
|
63
|
+
|
|
64
|
+
## After the changes
|
|
65
|
+
|
|
66
|
+
Re-render the touched scenes with `render-scenes.mjs --scene <id>` for each, then
|
|
67
|
+
look at a contact sheet before showing anything:
|
|
68
|
+
`ffmpeg -v error -y -i <run-dir>/final.mp4 -vf "fps=1,scale=320:-1,tile=6x6" -frames:v 1 <run-dir>/qa/sheet.jpg`.
|
|
69
|
+
Then rerun `build-reel.mjs`. The prompt
|
|
70
|
+
asks for version `N+1`; `build-reel.mjs` sets it. Reply with what changed, what
|
|
71
|
+
was skipped and why, and anything you could not do.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Write reel.json for the review player (editor/, Motion OS) from a rendered luckiest-video-studio run: one scene
|
|
3
|
+
// per storyboard scene, in storyboard order, with each scene's variables as editable fields. Rerunning bumps
|
|
4
|
+
// "version" so an open player reloads itself. <run-dir>/qa/review-notes.json ([{ t, x, y, text, scene? }]), when
|
|
5
|
+
// present, is pinned on the frame as Claude's own notes. Then serve it: node <skill-dir>/editor/serve.mjs <run-dir>
|
|
6
|
+
// Usage: node build-reel.mjs <run-dir>
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const MEDIA = { image: /\.(png|jpe?g|webp|gif|svg)$/i, video: /\.(mp4|mov|webm)$/i, audio: /\.(mp3|wav|m4a)$/i };
|
|
13
|
+
const mediaType = (v) => Object.keys(MEDIA).find((k) => MEDIA[k].test(v));
|
|
14
|
+
const slug = (v) => v.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
15
|
+
const words = (id) => id.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
16
|
+
|
|
17
|
+
// One field per variable: hex -> color, file path -> media, number -> number, other strings -> text.
|
|
18
|
+
// ponytail: nested objects and arrays are skipped (no field). Map them to "list" if storyboards start using them.
|
|
19
|
+
function field(k, v) {
|
|
20
|
+
const label = words(k);
|
|
21
|
+
if (typeof v === "number") return { type: "number", label, v };
|
|
22
|
+
if (typeof v !== "string") return null;
|
|
23
|
+
if (/^#[0-9a-f]{3,8}$/i.test(v)) return { type: "color", label, v };
|
|
24
|
+
if (mediaType(v)) return { type: "media", label, v };
|
|
25
|
+
return { type: v.length > 60 ? "longtext" : "text", label, v };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pure: the reel for a storyboard. prevVersion is the version of the reel.json already in the run, or 0.
|
|
29
|
+
export function buildReel(sb, runDir, prevVersion = 0, duration = 0, qaNotes = []) {
|
|
30
|
+
const assets = new Map();
|
|
31
|
+
// The player's color fields pick from the brand palette, so every distinct hex becomes a palette entry and the
|
|
32
|
+
// field links to it. Editing a palette color in the Style tab then changes every scene that uses it.
|
|
33
|
+
const colors = new Map();
|
|
34
|
+
const paletteId = (hex, k) => {
|
|
35
|
+
const key = hex.toLowerCase();
|
|
36
|
+
if (!colors.has(key)) colors.set(key, { id: `c${colors.size + 1}`, name: words(k), v: hex });
|
|
37
|
+
return `brand.${colors.get(key).id}`;
|
|
38
|
+
};
|
|
39
|
+
const asset = (id, from) => { const type = mediaType(id); if (type && !assets.has(id)) assets.set(id, { id, name: basename(id), type, from }); };
|
|
40
|
+
let t = 0;
|
|
41
|
+
const scenes = sb.scenes.map((s, i) => {
|
|
42
|
+
const range = [+t.toFixed(3), +(t += s.duration).toFixed(3)];
|
|
43
|
+
const props = {};
|
|
44
|
+
for (const [k, v] of Object.entries(s.variables ?? {})) {
|
|
45
|
+
const f = field(k, v);
|
|
46
|
+
if (!f) continue;
|
|
47
|
+
if (f.type === "color") f.v = paletteId(v, k);
|
|
48
|
+
props[k] = f;
|
|
49
|
+
if (f.type === "media") asset(v, "Run");
|
|
50
|
+
}
|
|
51
|
+
if (s.kind === "demo" && s.source) { props.source = { type: "media", label: "Footage", v: s.source }; asset(s.source, "Footage"); }
|
|
52
|
+
for (const a of s.assets ?? []) asset(a, "Run");
|
|
53
|
+
props.motion = { type: "motion", label: "Motion", v: [s.kind, s.recipe].filter(Boolean).join(", ") };
|
|
54
|
+
const src = s.kind === "demo" ? s.source : `composition/${s.composition ?? `compositions/${s.id}.html`}`;
|
|
55
|
+
return {
|
|
56
|
+
id: s.id, name: words(s.id), t: range,
|
|
57
|
+
els: [{ id: s.id, label: s.line || words(s.id), t: range, box: null, src: `storyboard.json scenes[${i}] · ${src}`, props }],
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
if (sb.music?.file) asset(sb.music.file, "Music");
|
|
61
|
+
return {
|
|
62
|
+
// The player stores unsent edits per id: title plus run folder keeps two projects from sharing them.
|
|
63
|
+
id: `${slug(sb.title)}-${slug(basename(runDir))}`,
|
|
64
|
+
title: sb.title,
|
|
65
|
+
version: prevVersion + 1,
|
|
66
|
+
path: runDir,
|
|
67
|
+
src: "final.mp4",
|
|
68
|
+
w: sb.format.width, h: sb.format.height, fps: sb.format.fps,
|
|
69
|
+
// The joined file can run a frame or two off the storyboard sum; trust the file when it was probed.
|
|
70
|
+
duration: duration || +t.toFixed(3),
|
|
71
|
+
brand: { colors: [...colors.values()], fonts: [], logo: null },
|
|
72
|
+
audio: { music: sb.music?.file ?? null, bpm: null, drop: null, musicVol: sb.music?.volume ?? 0.35, sfxVol: 0.8 },
|
|
73
|
+
assets: [...assets.values()],
|
|
74
|
+
scenes,
|
|
75
|
+
// ponytail: the player seeds notes only on a project's first open (later opens keep the browser's state),
|
|
76
|
+
// so these show once. Write them into the prompt by hand if a later version needs them.
|
|
77
|
+
notes: qaNotes.map((n, i) => ({ id: i + 1, t: n.t, x: n.x ?? 50, y: n.y ?? 50, el: n.scene ?? null, text: `Claude: ${n.text}` })),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
82
|
+
const runDir = resolve(process.argv[2] ?? ".");
|
|
83
|
+
const fail = (m) => { console.error(`error: ${m}`); process.exit(1); };
|
|
84
|
+
if (!existsSync(join(runDir, "storyboard.json"))) fail(`no storyboard.json in ${runDir}`);
|
|
85
|
+
if (!existsSync(join(runDir, "final.mp4"))) fail(`no final.mp4 in ${runDir}; run render-scenes.mjs first`);
|
|
86
|
+
const sb = JSON.parse(readFileSync(join(runDir, "storyboard.json"), "utf8"));
|
|
87
|
+
const reelFile = join(runDir, "reel.json");
|
|
88
|
+
const prev = existsSync(reelFile) ? JSON.parse(readFileSync(reelFile, "utf8")).version ?? 0 : 0;
|
|
89
|
+
const probe = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", join(runDir, "final.mp4")], { encoding: "utf8" });
|
|
90
|
+
const notesFile = join(runDir, "qa", "review-notes.json");
|
|
91
|
+
const qaNotes = existsSync(notesFile) ? JSON.parse(readFileSync(notesFile, "utf8")) : [];
|
|
92
|
+
const reel = buildReel(sb, runDir, prev, +(Number(probe.stdout?.trim()) || 0).toFixed(3), qaNotes);
|
|
93
|
+
writeFileSync(reelFile, JSON.stringify(reel, null, 1) + "\n");
|
|
94
|
+
const serve = join(dirname(fileURLToPath(import.meta.url)), "..", "editor", "serve.mjs");
|
|
95
|
+
console.log(`wrote ${reelFile} v${reel.version} (${reel.scenes.length} scenes, ${reel.duration}s). Open it: node "${resolve(serve)}" "${runDir}"`);
|
|
96
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildReel } from "./build-reel.mjs";
|
|
4
|
+
|
|
5
|
+
const sb = {
|
|
6
|
+
title: "Acme launch",
|
|
7
|
+
format: { width: 1920, height: 1080, fps: 30 },
|
|
8
|
+
music: { file: "audio/bed.mp3", volume: 0.3 },
|
|
9
|
+
scenes: [
|
|
10
|
+
{ id: "hook", kind: "motion", duration: 2.5, line: "Your invoices, done", variables: { headline: "Done in one click", accent: "#5B5BF7", logo: "brand/logo.svg", nested: { a: 1 } } },
|
|
11
|
+
{ id: "create-invoice", kind: "demo", duration: 6, source: "demo/create.mp4" },
|
|
12
|
+
{ id: "logo-close", kind: "logo", recipe: "piece-assembly", duration: 3, assets: ["brand/logo.svg"] },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("scenes keep storyboard ids and cover 0 to duration with no gaps", () => {
|
|
17
|
+
const reel = buildReel(sb, "/runs/Video-Studio-Output");
|
|
18
|
+
assert.equal(reel.id, "acme-launch-video-studio-output");
|
|
19
|
+
assert.equal(reel.src, "final.mp4");
|
|
20
|
+
assert.deepEqual([reel.w, reel.h, reel.fps], [1920, 1080, 30]);
|
|
21
|
+
assert.deepEqual(reel.scenes.map((s) => [s.id, s.t]), [["hook", [0, 2.5]], ["create-invoice", [2.5, 8.5]], ["logo-close", [8.5, 11.5]]]);
|
|
22
|
+
assert.equal(reel.duration, 11.5);
|
|
23
|
+
assert.equal(buildReel(sb, "/r", 0, 11.533).duration, 11.533);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("variables become typed fields, nested values are skipped", () => {
|
|
27
|
+
const { props, label, box } = buildReel(sb, "/r").scenes[0].els[0];
|
|
28
|
+
assert.equal(label, "Your invoices, done");
|
|
29
|
+
assert.equal(box, null);
|
|
30
|
+
assert.deepEqual(Object.fromEntries(Object.entries(props).map(([k, p]) => [k, p.type])), { headline: "text", accent: "color", logo: "media", motion: "motion" });
|
|
31
|
+
assert.equal(props.headline.v, "Done in one click");
|
|
32
|
+
assert.equal(props.accent.v, "brand.c1");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("hex values become one shared palette entry each", () => {
|
|
36
|
+
const two = { ...sb, scenes: [sb.scenes[0], { id: "cta", kind: "motion", duration: 2, variables: { bg: "#5b5bf7", ink: "#111111" } }] };
|
|
37
|
+
const reel = buildReel(two, "/r");
|
|
38
|
+
assert.deepEqual(reel.brand.colors, [{ id: "c1", name: "Accent", v: "#5B5BF7" }, { id: "c2", name: "Ink", v: "#111111" }]);
|
|
39
|
+
assert.deepEqual(Object.values(reel.scenes[1].els[0].props).filter((p) => p.type === "color").map((p) => p.v), ["brand.c1", "brand.c2"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("demo footage, scene assets and music are listed once as assets", () => {
|
|
43
|
+
const reel = buildReel(sb, "/r");
|
|
44
|
+
assert.deepEqual(reel.assets.map((a) => [a.id, a.type]), [["brand/logo.svg", "image"], ["demo/create.mp4", "video"], ["audio/bed.mp3", "audio"]]);
|
|
45
|
+
assert.equal(reel.scenes[1].els[0].props.source.v, "demo/create.mp4");
|
|
46
|
+
assert.equal(reel.scenes[2].els[0].props.motion.v, "logo, piece-assembly");
|
|
47
|
+
assert.equal(reel.audio.music, "audio/bed.mp3");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("Claude's review notes are pinned with ids and a Claude: prefix", () => {
|
|
51
|
+
const { notes } = buildReel(sb, "/r", 0, 0, [{ t: 1.2, x: 30, y: 70, text: "Headline sits on the logo", scene: "hook" }, { t: 9, text: "Outro feels long" }]);
|
|
52
|
+
assert.deepEqual(notes, [
|
|
53
|
+
{ id: 1, t: 1.2, x: 30, y: 70, el: "hook", text: "Claude: Headline sits on the logo" },
|
|
54
|
+
{ id: 2, t: 9, x: 50, y: 50, el: null, text: "Claude: Outro feels long" },
|
|
55
|
+
]);
|
|
56
|
+
assert.deepEqual(buildReel(sb, "/r").notes, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rerunning bumps the version so an open player reloads", () => {
|
|
60
|
+
assert.equal(buildReel(sb, "/r").version, 1);
|
|
61
|
+
assert.equal(buildReel(sb, "/r", 4).version, 5);
|
|
62
|
+
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Build a self-contained review page for an edit decision list (EDL).
|
|
3
|
-
// Visualizes kept (green) vs cut (red) regions on a timeline, lists the biggest
|
|
4
|
-
// cuts, and embeds the original + edited videos side by side so you can scrub,
|
|
5
|
-
// click any cut to hear what was removed, and watch the result.
|
|
6
|
-
//
|
|
7
|
-
// Reusable across pipeline agents that emit an EDL with { source_duration,
|
|
8
|
-
// edited_duration, removed, keep_ranges, delete_ranges }.
|
|
9
|
-
//
|
|
10
|
-
// Usage:
|
|
11
|
-
// node build-edl-review.mjs <edl.json> --original <video> --edited <video> [--output review.html]
|
|
12
|
-
|
|
13
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { resolve, dirname, join, relative, basename } from "node:path";
|
|
15
|
-
import { argv, exit } from "node:process";
|
|
16
|
-
|
|
17
|
-
const args = argv.slice(2);
|
|
18
|
-
if (args.length === 0 || args.includes("--help")) {
|
|
19
|
-
console.log("Usage: node build-edl-review.mjs <edl.json> --original <video> --edited <video> [--output review.html]");
|
|
20
|
-
exit(args.length === 0 ? 1 : 0);
|
|
21
|
-
}
|
|
22
|
-
const opts = { edl: null, original: null, edited: null, output: null };
|
|
23
|
-
for (let i = 0; i < args.length; i++) {
|
|
24
|
-
const a = args[i];
|
|
25
|
-
if (a === "--original") opts.original = args[++i];
|
|
26
|
-
else if (a === "--edited") opts.edited = args[++i];
|
|
27
|
-
else if (a === "--output" || a === "-o") opts.output = args[++i];
|
|
28
|
-
else if (!opts.edl) opts.edl = a;
|
|
29
|
-
}
|
|
30
|
-
if (!opts.edl) { console.error("no edl.json given"); exit(1); }
|
|
31
|
-
|
|
32
|
-
const edlPath = resolve(opts.edl);
|
|
33
|
-
const edl = JSON.parse(readFileSync(edlPath, "utf8"));
|
|
34
|
-
const outPath = resolve(opts.output ?? join(dirname(edlPath), "silence-review.html"));
|
|
35
|
-
const outDir = dirname(outPath);
|
|
36
|
-
const rel = (p) => (p ? encodeURI(relative(outDir, resolve(p))) : null);
|
|
37
|
-
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
38
|
-
const edlName = esc(basename(edlPath));
|
|
39
|
-
|
|
40
|
-
const dur = edl.source_duration;
|
|
41
|
-
const deletes = (edl.delete_ranges ?? []).map((d) => ({
|
|
42
|
-
start: d.start, end: d.end, len: d.end - d.start,
|
|
43
|
-
reasons: d.reasons ?? (d.reason ? [d.reason] : []),
|
|
44
|
-
}));
|
|
45
|
-
const keeps = edl.keep_ranges ?? [];
|
|
46
|
-
|
|
47
|
-
function fmt(s) {
|
|
48
|
-
const m = Math.floor(s / 60);
|
|
49
|
-
return `${m}:${(s - m * 60).toFixed(2).padStart(5, "0")}`;
|
|
50
|
-
}
|
|
51
|
-
const pct = (s) => (s / dur) * 100;
|
|
52
|
-
|
|
53
|
-
const keepRects = keeps.map((k) =>
|
|
54
|
-
`<div class="seg keep" style="left:${pct(k.start)}%;width:${pct(k.end - k.start)}%" title="keep ${fmt(k.start)}-${fmt(k.end)}"></div>`).join("");
|
|
55
|
-
const cutRects = deletes.map((d, i) =>
|
|
56
|
-
`<div class="seg cut" style="left:${pct(d.start)}%;width:${Math.max(pct(d.len), 0.12)}%" data-start="${d.start}" data-len="${d.len.toFixed(2)}" title="cut #${i + 1}: ${fmt(d.start)}-${fmt(d.end)} (${d.len.toFixed(2)}s)"></div>`).join("");
|
|
57
|
-
|
|
58
|
-
const topCuts = [...deletes].sort((a, b) => b.len - a.len).slice(0, 20);
|
|
59
|
-
const rows = topCuts.map((d, i) =>
|
|
60
|
-
`<tr><td>${i + 1}</td><td><button class="jump" data-t="${Math.max(0, d.start - 0.5).toFixed(2)}">${fmt(d.start)}</button></td><td>${fmt(d.end)}</td><td><b>${d.len.toFixed(2)}s</b></td><td class="reason">${esc(d.reasons[0] ?? "")}</td></tr>`).join("");
|
|
61
|
-
|
|
62
|
-
const removed = edl.removed ?? (dur - edl.edited_duration);
|
|
63
|
-
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Silence cut review: ${edlName}</title>
|
|
64
|
-
<style>
|
|
65
|
-
:root{--bg:#0d1117;--panel:#161b22;--line:#30363d;--green:#3fb950;--red:#f85149;--text:#e6edf3;--dim:#8b949e}
|
|
66
|
-
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,Inter,Segoe UI,sans-serif;padding:24px}
|
|
67
|
-
h1{font-size:18px;margin:0 0 4px} .sub{color:var(--dim);margin-bottom:20px}
|
|
68
|
-
.stats{display:flex;gap:24px;margin:0 0 20px;flex-wrap:wrap}
|
|
69
|
-
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 18px}
|
|
70
|
-
.stat .n{font-size:22px;font-weight:700} .stat .l{color:var(--dim);font-size:12px}
|
|
71
|
-
.stat .n.red{color:var(--red)} .stat .n.green{color:var(--green)}
|
|
72
|
-
.timeline{position:relative;height:46px;background:#21262d;border:1px solid var(--line);border-radius:8px;overflow:hidden;margin-bottom:6px}
|
|
73
|
-
.seg{position:absolute;top:0;height:100%} .seg.keep{background:var(--green);opacity:.55} .seg.cut{background:var(--red);cursor:pointer} .seg.cut:hover{opacity:.8;outline:1px solid #fff}
|
|
74
|
-
.axis{display:flex;justify-content:space-between;color:var(--dim);font-size:11px;margin-bottom:24px}
|
|
75
|
-
.legend{display:flex;gap:16px;font-size:12px;color:var(--dim);margin-bottom:24px}
|
|
76
|
-
.swatch{display:inline-block;width:12px;height:12px;border-radius:3px;vertical-align:-1px;margin-right:5px}
|
|
77
|
-
.videos{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:28px}
|
|
78
|
-
.vid{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px}
|
|
79
|
-
.vid h3{margin:0 0 8px;font-size:13px;color:var(--dim);font-weight:600} video{width:100%;border-radius:6px;background:#000}
|
|
80
|
-
table{border-collapse:collapse;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
81
|
-
th,td{padding:7px 12px;text-align:left;border-bottom:1px solid var(--line);font-size:13px} th{color:var(--dim);font-weight:600}
|
|
82
|
-
td .reason{color:var(--dim)} button.jump{background:#21262d;color:var(--text);border:1px solid var(--line);border-radius:5px;padding:2px 8px;cursor:pointer;font:inherit}
|
|
83
|
-
button.jump:hover{border-color:var(--green)}
|
|
84
|
-
.hint{color:var(--dim);font-size:12px;margin:6px 0 18px}
|
|
85
|
-
</style></head><body>
|
|
86
|
-
<h1>Cut-Silences review</h1>
|
|
87
|
-
<div class="sub">${edlName} · params: gap≥${edl.params?.gap ?? "?"}s, head-pad ${edl.params?.headPad ?? "?"}s, tail-pad ${edl.params?.tailPad ?? "?"}s</div>
|
|
88
|
-
<div class="stats">
|
|
89
|
-
<div class="stat"><div class="n">${fmt(dur)}</div><div class="l">original</div></div>
|
|
90
|
-
<div class="stat"><div class="n green">${fmt(edl.edited_duration)}</div><div class="l">edited</div></div>
|
|
91
|
-
<div class="stat"><div class="n red">−${removed.toFixed(1)}s</div><div class="l">silence removed</div></div>
|
|
92
|
-
<div class="stat"><div class="n">${((removed / dur) * 100).toFixed(1)}%</div><div class="l">tightened</div></div>
|
|
93
|
-
<div class="stat"><div class="n">${deletes.length}</div><div class="l">cuts</div></div>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="timeline" id="tl">${keepRects}${cutRects}</div>
|
|
96
|
-
<div class="axis"><span>0:00</span><span>${fmt(dur / 2)}</span><span>${fmt(dur)}</span></div>
|
|
97
|
-
<div class="legend"><span><span class="swatch" style="background:var(--green)"></span>kept speech</span><span><span class="swatch" style="background:var(--red)"></span>silence cut</span><span>· click a red mark (or a row below) to jump the ORIGINAL player to just before that pause</span></div>
|
|
98
|
-
<div class="videos">
|
|
99
|
-
<div class="vid"><h3>ORIGINAL (with silences)</h3><video id="orig" src="${rel(opts.original) ?? ""}" controls preload="metadata"></video></div>
|
|
100
|
-
<div class="vid"><h3>EDITED (silences cut)</h3><video id="edit" src="${rel(opts.edited) ?? ""}" controls preload="metadata"></video></div>
|
|
101
|
-
</div>
|
|
102
|
-
<div class="hint">Tip: play the EDITED one straight through to judge pacing. Use the cut markers + ORIGINAL player to spot-check that no words were clipped at a cut boundary.</div>
|
|
103
|
-
<h3 style="font-size:14px">20 largest pauses removed</h3>
|
|
104
|
-
<table><thead><tr><th>#</th><th>from</th><th>to</th><th>removed</th><th>reason</th></tr></thead><tbody>${rows}</tbody></table>
|
|
105
|
-
<script>
|
|
106
|
-
const orig = document.getElementById('orig');
|
|
107
|
-
function jump(t){ orig.currentTime = Math.max(0, t); orig.play(); orig.scrollIntoView({behavior:'smooth',block:'center'}); }
|
|
108
|
-
document.querySelectorAll('.seg.cut').forEach(s => s.addEventListener('click', () => jump(parseFloat(s.dataset.start) - 0.5)));
|
|
109
|
-
document.querySelectorAll('button.jump').forEach(b => b.addEventListener('click', () => jump(parseFloat(b.dataset.t))));
|
|
110
|
-
</script>
|
|
111
|
-
</body></html>`;
|
|
112
|
-
|
|
113
|
-
writeFileSync(outPath, html);
|
|
114
|
-
console.log(JSON.stringify({ review: outPath, cuts: deletes.length, removed: Number(removed.toFixed(2)) }, null, 2));
|