video-editing 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/README.md +83 -0
- package/dist/assets/DejaVuSansMono.ttf +0 -0
- package/dist/chunk-63HIQBDT.js +4321 -0
- package/dist/chunk-CU3NBKB4.js +307 -0
- package/dist/cli.js +1594 -0
- package/dist/index.d.ts +3569 -0
- package/dist/index.js +300 -0
- package/dist/job-OFCSLPBV.js +25 -0
- package/dist/templates/author-edl.prompt.md +33 -0
- package/dist/templates/fix.prompt.md +23 -0
- package/dist/templates/review.prompt.md +30 -0
- package/package.json +69 -0
- package/skills/video-editing/SKILL.md +254 -0
- package/skills/video-editing/references/adversarial-review.md +44 -0
- package/skills/video-editing/references/cli-reference.md +181 -0
- package/skills/video-editing/references/editing-brief.md +44 -0
- package/skills/video-editing/references/hard-rules.md +38 -0
- package/skills/video-editing/references/scribe-collapse.md +37 -0
- package/skills/video-editing/references/timeline-v2-vocabulary.md +162 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/job.ts
|
|
4
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join, parse as parsePath, resolve as resolvePath } from "path";
|
|
7
|
+
|
|
8
|
+
// src/ffmpeg.ts
|
|
9
|
+
import { spawn, spawnSync } from "child_process";
|
|
10
|
+
import { createWriteStream, mkdirSync } from "fs";
|
|
11
|
+
import { dirname } from "path";
|
|
12
|
+
function shquote(value) {
|
|
13
|
+
if (value.length === 0) return "''";
|
|
14
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
15
|
+
return "'" + value.replace(/'/g, `'"'"'`) + "'";
|
|
16
|
+
}
|
|
17
|
+
function shown(cmd) {
|
|
18
|
+
return cmd.map(shquote).join(" ");
|
|
19
|
+
}
|
|
20
|
+
var CommandError = class extends Error {
|
|
21
|
+
constructor(message, code, result) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.result = result;
|
|
25
|
+
this.name = "CommandError";
|
|
26
|
+
}
|
|
27
|
+
code;
|
|
28
|
+
result;
|
|
29
|
+
};
|
|
30
|
+
function run(cmd, opts = {}) {
|
|
31
|
+
const { capture = false, stdoutPath, cwd, check = true, echo = true } = opts;
|
|
32
|
+
if (echo) process.stderr.write(`+ ${shown(cmd)}
|
|
33
|
+
`);
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const [bin, ...args] = cmd;
|
|
36
|
+
if (!bin) {
|
|
37
|
+
reject(new Error("empty command"));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let fileStream;
|
|
41
|
+
const outChunks = [];
|
|
42
|
+
const errChunks = [];
|
|
43
|
+
const stdio = ["ignore", "pipe", "pipe"];
|
|
44
|
+
if (!capture && !stdoutPath) {
|
|
45
|
+
stdio[1] = "inherit";
|
|
46
|
+
stdio[2] = "inherit";
|
|
47
|
+
}
|
|
48
|
+
if (stdoutPath) {
|
|
49
|
+
mkdirSync(dirname(stdoutPath), { recursive: true });
|
|
50
|
+
fileStream = createWriteStream(stdoutPath, { encoding: "utf8" });
|
|
51
|
+
}
|
|
52
|
+
const child = spawn(bin, args, { cwd, stdio });
|
|
53
|
+
child.stdout?.on("data", (d) => {
|
|
54
|
+
if (fileStream) fileStream.write(d);
|
|
55
|
+
else if (capture) outChunks.push(d);
|
|
56
|
+
});
|
|
57
|
+
child.stderr?.on("data", (d) => {
|
|
58
|
+
if (fileStream) fileStream.write(d);
|
|
59
|
+
else if (capture) errChunks.push(d);
|
|
60
|
+
});
|
|
61
|
+
child.on("error", (err) => reject(err));
|
|
62
|
+
child.on("close", (code) => {
|
|
63
|
+
if (fileStream) fileStream.end();
|
|
64
|
+
const result = {
|
|
65
|
+
code: code ?? -1,
|
|
66
|
+
stdout: Buffer.concat(outChunks).toString("utf8"),
|
|
67
|
+
stderr: Buffer.concat(errChunks).toString("utf8")
|
|
68
|
+
};
|
|
69
|
+
if (check && result.code !== 0) {
|
|
70
|
+
if (capture) {
|
|
71
|
+
if (result.stdout) process.stderr.write(result.stdout + "\n");
|
|
72
|
+
if (result.stderr) process.stderr.write(result.stderr + "\n");
|
|
73
|
+
}
|
|
74
|
+
reject(new CommandError(`command failed with exit ${result.code}: ${shown(cmd)}`, result.code, result));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
resolve(result);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function runSync(cmd, opts = {}) {
|
|
82
|
+
const { check = true, echo = true } = opts;
|
|
83
|
+
if (echo) process.stderr.write(`+ ${shown(cmd)}
|
|
84
|
+
`);
|
|
85
|
+
const [bin, ...args] = cmd;
|
|
86
|
+
const proc = spawnSync(bin, args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
87
|
+
const result = { code: proc.status ?? -1, stdout: proc.stdout ?? "", stderr: proc.stderr ?? "" };
|
|
88
|
+
if (check && result.code !== 0) {
|
|
89
|
+
throw new CommandError(`command failed with exit ${result.code}: ${shown(cmd)}`, result.code, result);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
var FFPROBE_ENTRIES = "stream=index,codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,duration,color_transfer:stream_side_data=rotation:format=duration,size,format_name";
|
|
94
|
+
async function ffprobe(path) {
|
|
95
|
+
const res = await run(
|
|
96
|
+
["ffprobe", "-v", "error", "-show_entries", FFPROBE_ENTRIES, "-of", "json", path],
|
|
97
|
+
{ capture: true }
|
|
98
|
+
);
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(res.stdout || "{}");
|
|
101
|
+
} catch (e) {
|
|
102
|
+
throw new Error(`ffprobe returned invalid JSON for ${path}: ${e.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function videoStream(probe) {
|
|
106
|
+
for (const s of probe.streams ?? []) {
|
|
107
|
+
if (s.codec_type === "video") return s;
|
|
108
|
+
}
|
|
109
|
+
throw new Error("no video stream found");
|
|
110
|
+
}
|
|
111
|
+
async function mediaDuration(path) {
|
|
112
|
+
const probe = await ffprobe(path);
|
|
113
|
+
const d = probe.format?.duration;
|
|
114
|
+
const n = d ? Number(d) : 0;
|
|
115
|
+
return Number.isFinite(n) ? n : 0;
|
|
116
|
+
}
|
|
117
|
+
function requireFfmpeg() {
|
|
118
|
+
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
119
|
+
const r = spawnSync(bin, ["-version"], { encoding: "utf8" });
|
|
120
|
+
if (r.error) {
|
|
121
|
+
throw new Error("ffmpeg and ffprobe must be on PATH");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/probe.ts
|
|
127
|
+
function rotationDegrees(stream) {
|
|
128
|
+
for (const sideData of stream.side_data_list ?? []) {
|
|
129
|
+
if (sideData && "rotation" in sideData) {
|
|
130
|
+
const v = Number(sideData.rotation);
|
|
131
|
+
return Number.isFinite(v) ? Math.trunc(v) : 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const tags = stream.tags ?? {};
|
|
135
|
+
if ("rotate" in tags) {
|
|
136
|
+
const v = Number(tags.rotate);
|
|
137
|
+
return Number.isFinite(v) ? Math.trunc(v) : 0;
|
|
138
|
+
}
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
function displayedDimensions(stream) {
|
|
142
|
+
const width = Math.trunc(Number(stream.width ?? 0)) || 0;
|
|
143
|
+
const height = Math.trunc(Number(stream.height ?? 0)) || 0;
|
|
144
|
+
const rotation = rotationDegrees(stream);
|
|
145
|
+
if (Math.abs(rotation) % 180 === 90) {
|
|
146
|
+
return [height, width, rotation];
|
|
147
|
+
}
|
|
148
|
+
return [width, height, rotation];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/util.ts
|
|
152
|
+
function slugify(value) {
|
|
153
|
+
let slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
154
|
+
slug = slug.replace(/-{2,}/g, "-");
|
|
155
|
+
return slug || "job";
|
|
156
|
+
}
|
|
157
|
+
function isoNow() {
|
|
158
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
|
159
|
+
}
|
|
160
|
+
function pyRound(x, ndigits = 0) {
|
|
161
|
+
if (!Number.isFinite(x)) return x;
|
|
162
|
+
const m = Math.pow(10, ndigits);
|
|
163
|
+
const scaled = x * m;
|
|
164
|
+
const floor = Math.floor(scaled);
|
|
165
|
+
const diff = scaled - floor;
|
|
166
|
+
const EPS = 1e-9;
|
|
167
|
+
let rounded;
|
|
168
|
+
if (Math.abs(diff - 0.5) < EPS) {
|
|
169
|
+
rounded = floor % 2 === 0 ? floor : floor + 1;
|
|
170
|
+
} else {
|
|
171
|
+
rounded = Math.round(scaled);
|
|
172
|
+
}
|
|
173
|
+
return rounded / m;
|
|
174
|
+
}
|
|
175
|
+
function pyFixed(x, prec) {
|
|
176
|
+
if (!Number.isFinite(x)) return String(x);
|
|
177
|
+
const neg = x < 0 || Object.is(x, -0);
|
|
178
|
+
const a = Math.abs(x);
|
|
179
|
+
const m = Math.pow(10, prec);
|
|
180
|
+
const scaled = a * m;
|
|
181
|
+
const floor = Math.floor(scaled);
|
|
182
|
+
const diff = scaled - floor;
|
|
183
|
+
const EPS = 1e-9;
|
|
184
|
+
let rounded;
|
|
185
|
+
if (Math.abs(diff - 0.5) < EPS) {
|
|
186
|
+
rounded = floor % 2 === 0 ? floor : floor + 1;
|
|
187
|
+
} else {
|
|
188
|
+
rounded = Math.round(scaled);
|
|
189
|
+
}
|
|
190
|
+
let s = (rounded / m).toFixed(prec);
|
|
191
|
+
if (neg && Number(s) !== 0) s = "-" + s;
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
194
|
+
function pyFixedPad(x, width, prec) {
|
|
195
|
+
const body = pyFixed(x, prec);
|
|
196
|
+
if (body.length >= width) return body;
|
|
197
|
+
const neg = body.startsWith("-");
|
|
198
|
+
if (neg) {
|
|
199
|
+
return "-" + body.slice(1).padStart(width - 1, "0");
|
|
200
|
+
}
|
|
201
|
+
return body.padStart(width, "0");
|
|
202
|
+
}
|
|
203
|
+
function pySpacePad(x, width, prec) {
|
|
204
|
+
return pyFixed(x, prec).padStart(width, " ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/job.ts
|
|
208
|
+
var DEFAULT_EDIT_ROOT = join(homedir(), "Downloads", "edit");
|
|
209
|
+
var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".MP4", ".mov", ".MOV", ".mkv", ".MKV", ".avi", ".AVI", ".m4v"]);
|
|
210
|
+
function defaultJobDir(source) {
|
|
211
|
+
return join(DEFAULT_EDIT_ROOT, `job_${slugify(parsePath(source).name)}`);
|
|
212
|
+
}
|
|
213
|
+
function sourceJsonPath(jobDir) {
|
|
214
|
+
return join(jobDir, "source.json");
|
|
215
|
+
}
|
|
216
|
+
function transcriptPath(jobDir, stem) {
|
|
217
|
+
return join(jobDir, "transcripts", `${stem}.json`);
|
|
218
|
+
}
|
|
219
|
+
function ensureDirs(jobDir) {
|
|
220
|
+
for (const name of ["transcripts", "qc", "logs", "prompts"]) {
|
|
221
|
+
mkdirSync2(join(jobDir, name), { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function initJob(source, jobDir, opts = {}) {
|
|
225
|
+
const src = resolvePath(source);
|
|
226
|
+
if (!existsSync(src)) throw new Error(`source not found: ${src}`);
|
|
227
|
+
const ext = parsePath(src).ext;
|
|
228
|
+
if (!VIDEO_EXTS.has(ext)) process.stderr.write(`warning: unusual video extension: ${ext}
|
|
229
|
+
`);
|
|
230
|
+
const dir = resolvePath(jobDir ?? defaultJobDir(src));
|
|
231
|
+
if (existsSync(sourceJsonPath(dir)) && !opts.force) {
|
|
232
|
+
ensureDirs(dir);
|
|
233
|
+
return dir;
|
|
234
|
+
}
|
|
235
|
+
mkdirSync2(dir, { recursive: true });
|
|
236
|
+
ensureDirs(dir);
|
|
237
|
+
const probe = await ffprobe(src);
|
|
238
|
+
const stream = videoStream(probe);
|
|
239
|
+
const [displayW, displayH, rotation] = displayedDimensions(stream);
|
|
240
|
+
const payload = {
|
|
241
|
+
created: isoNow(),
|
|
242
|
+
source: src,
|
|
243
|
+
stem: parsePath(src).name,
|
|
244
|
+
job_dir: dir,
|
|
245
|
+
duration_s: Number(probe.format?.duration ?? 0) || 0,
|
|
246
|
+
stored_width: Math.trunc(Number(stream.width ?? 0)) || 0,
|
|
247
|
+
stored_height: Math.trunc(Number(stream.height ?? 0)) || 0,
|
|
248
|
+
display_width: displayW,
|
|
249
|
+
display_height: displayH,
|
|
250
|
+
rotation,
|
|
251
|
+
probe
|
|
252
|
+
};
|
|
253
|
+
writeFileSync(sourceJsonPath(dir), JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
254
|
+
return dir;
|
|
255
|
+
}
|
|
256
|
+
function loadJob(jobDir) {
|
|
257
|
+
const path = sourceJsonPath(jobDir);
|
|
258
|
+
if (!existsSync(path)) throw new Error(`job has no source.json: ${jobDir}`);
|
|
259
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
260
|
+
}
|
|
261
|
+
function sourceFromJob(jobDir) {
|
|
262
|
+
const job = loadJob(jobDir);
|
|
263
|
+
const source = job.source;
|
|
264
|
+
if (!existsSync(source)) throw new Error(`source listed in job does not exist: ${source}`);
|
|
265
|
+
return resolvePath(source);
|
|
266
|
+
}
|
|
267
|
+
async function resolveJob(args, opts = {}) {
|
|
268
|
+
let source = args.source ? resolvePath(args.source) : null;
|
|
269
|
+
let jobDir = args.jobDir ? resolvePath(args.jobDir) : null;
|
|
270
|
+
if (source) {
|
|
271
|
+
jobDir = await initJob(source, jobDir, { force: opts.forceJob });
|
|
272
|
+
} else if (jobDir) {
|
|
273
|
+
source = sourceFromJob(jobDir);
|
|
274
|
+
} else {
|
|
275
|
+
throw new Error("provide --source or --job-dir");
|
|
276
|
+
}
|
|
277
|
+
return { jobDir, source, stem: parsePath(source).name };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
shown,
|
|
282
|
+
CommandError,
|
|
283
|
+
run,
|
|
284
|
+
runSync,
|
|
285
|
+
ffprobe,
|
|
286
|
+
videoStream,
|
|
287
|
+
mediaDuration,
|
|
288
|
+
requireFfmpeg,
|
|
289
|
+
slugify,
|
|
290
|
+
isoNow,
|
|
291
|
+
pyRound,
|
|
292
|
+
pyFixed,
|
|
293
|
+
pyFixedPad,
|
|
294
|
+
pySpacePad,
|
|
295
|
+
rotationDegrees,
|
|
296
|
+
displayedDimensions,
|
|
297
|
+
DEFAULT_EDIT_ROOT,
|
|
298
|
+
VIDEO_EXTS,
|
|
299
|
+
defaultJobDir,
|
|
300
|
+
sourceJsonPath,
|
|
301
|
+
transcriptPath,
|
|
302
|
+
ensureDirs,
|
|
303
|
+
initJob,
|
|
304
|
+
loadJob,
|
|
305
|
+
sourceFromJob,
|
|
306
|
+
resolveJob
|
|
307
|
+
};
|