video-editing 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-W4IIL2T2.js → chunk-2OM7WV4W.js} +15 -0
- package/dist/cli.js +113 -12
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skills/video-editing/SKILL.md +6 -0
- package/skills/video-editing/references/audio-joins.md +94 -0
- package/skills/video-editing/references/cli-reference.md +10 -0
|
@@ -104,6 +104,9 @@ function resolveUrl(base, videoId) {
|
|
|
104
104
|
function verifyUrl(base, videoId) {
|
|
105
105
|
return videoUrl(base, videoId, "verify");
|
|
106
106
|
}
|
|
107
|
+
function smoothJoinsUrl(base, videoId) {
|
|
108
|
+
return videoUrl(base, videoId, "smooth-joins");
|
|
109
|
+
}
|
|
107
110
|
function composeUrl(base, videoId) {
|
|
108
111
|
return videoUrl(base, videoId, "compose");
|
|
109
112
|
}
|
|
@@ -653,6 +656,18 @@ var CloudClient = class _CloudClient {
|
|
|
653
656
|
}
|
|
654
657
|
return { runId: o.run_id };
|
|
655
658
|
}
|
|
659
|
+
async smoothJoinsStart(body = {}) {
|
|
660
|
+
const data = await this.request(
|
|
661
|
+
"POST",
|
|
662
|
+
smoothJoinsUrl(this.cfg.base, this.requireVideoId()),
|
|
663
|
+
body
|
|
664
|
+
);
|
|
665
|
+
const o = asRecord(data, "smooth-joins");
|
|
666
|
+
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
667
|
+
throw new Error(`smooth-joins response missing run_id: ${JSON.stringify(data)}`);
|
|
668
|
+
}
|
|
669
|
+
return { runId: o.run_id };
|
|
670
|
+
}
|
|
656
671
|
/** GET /runs/{id} — run status + events. */
|
|
657
672
|
async getRun(runId) {
|
|
658
673
|
const data = await this.request("GET", runUrl(this.cfg.base, runId));
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import {
|
|
3
3
|
CloudClient,
|
|
4
4
|
REVIEW_STAGES2,
|
|
5
|
+
assertRunCompleted,
|
|
5
6
|
compileRemote,
|
|
6
7
|
defaultRenderOut,
|
|
8
|
+
formatRunEvent,
|
|
7
9
|
framesPngPath,
|
|
8
10
|
initCloudJob,
|
|
9
11
|
inspectPngPath,
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
loadTimeline2,
|
|
17
19
|
outWindowToSourceWindows,
|
|
18
20
|
pollRender,
|
|
21
|
+
pollRun,
|
|
19
22
|
publicConfigEnvPath,
|
|
20
23
|
pullFiles,
|
|
21
24
|
pullReview2,
|
|
@@ -41,12 +44,56 @@ import {
|
|
|
41
44
|
waveformPngPath,
|
|
42
45
|
waveformWordsPath,
|
|
43
46
|
webVideoUrl
|
|
44
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-2OM7WV4W.js";
|
|
45
48
|
|
|
46
49
|
// src/public/cli.ts
|
|
47
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
48
|
-
import { join, resolve as resolvePath } from "path";
|
|
50
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
51
|
+
import { join as join2, resolve as resolvePath } from "path";
|
|
49
52
|
import { Command } from "commander";
|
|
53
|
+
|
|
54
|
+
// src/cloud/smooth-joins.ts
|
|
55
|
+
import { existsSync, readFileSync } from "fs";
|
|
56
|
+
import { join } from "path";
|
|
57
|
+
var JOINED_REPORT_REL = "audio/joined.json";
|
|
58
|
+
async function smoothJoinsCloud(client, jobDir, opts = {}) {
|
|
59
|
+
const verbose = opts.verbose !== false;
|
|
60
|
+
const log = (line) => {
|
|
61
|
+
if (verbose) process.stderr.write(line + "\n");
|
|
62
|
+
};
|
|
63
|
+
const { runId } = await client.smoothJoinsStart({
|
|
64
|
+
...opts.crossfadeMs !== void 0 ? { crossfade_ms: opts.crossfadeMs } : {},
|
|
65
|
+
...opts.curve !== void 0 ? { curve: opts.curve } : {},
|
|
66
|
+
...opts.maxMs !== void 0 ? { max_ms: opts.maxMs } : {},
|
|
67
|
+
...opts.overrides !== void 0 && opts.overrides.length > 0 ? { joins: opts.overrides.map((o) => ({ index: o.index, crossfade_ms: o.crossfadeMs })) } : {}
|
|
68
|
+
});
|
|
69
|
+
log(` smooth-joins run: ${runId}`);
|
|
70
|
+
const state = await pollRun(client, runId, {
|
|
71
|
+
intervalMs: opts.pollIntervalMs,
|
|
72
|
+
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
73
|
+
});
|
|
74
|
+
assertRunCompleted(state, "smooth-joins");
|
|
75
|
+
await pullFiles(client, jobDir, { only: [JOINED_REPORT_REL] });
|
|
76
|
+
const reportPath = join(jobDir, JOINED_REPORT_REL);
|
|
77
|
+
let joins = [];
|
|
78
|
+
let totalDurationS = null;
|
|
79
|
+
if (existsSync(reportPath)) {
|
|
80
|
+
try {
|
|
81
|
+
const r = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
82
|
+
if (Array.isArray(r.joins)) joins = r.joins;
|
|
83
|
+
if (typeof r.total_duration_s === "number") totalDurationS = r.total_duration_s;
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
runId,
|
|
89
|
+
reportPath,
|
|
90
|
+
joins,
|
|
91
|
+
clamped: joins.filter((j) => j.clamped).length,
|
|
92
|
+
totalDurationS
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/public/cli.ts
|
|
50
97
|
loadPublicConfigEnv();
|
|
51
98
|
function num(v) {
|
|
52
99
|
if (v === void 0) return void 0;
|
|
@@ -87,7 +134,7 @@ function jobDirOf(o) {
|
|
|
87
134
|
}
|
|
88
135
|
var pkg;
|
|
89
136
|
try {
|
|
90
|
-
pkg = JSON.parse(
|
|
137
|
+
pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
91
138
|
} catch {
|
|
92
139
|
pkg = { version: "3.0.0" };
|
|
93
140
|
}
|
|
@@ -264,8 +311,8 @@ addCloudOptions(
|
|
|
264
311
|
await withCloudErrors(!!o.json, async () => {
|
|
265
312
|
const jobDir = jobDirOf(o);
|
|
266
313
|
const tlPath = timelineJsonPath2(jobDir);
|
|
267
|
-
if (!
|
|
268
|
-
const raw = JSON.parse(
|
|
314
|
+
if (!existsSync2(tlPath)) throw new Error(`timeline not found: ${tlPath}`);
|
|
315
|
+
const raw = JSON.parse(readFileSync2(tlPath, "utf8"));
|
|
269
316
|
const captions = raw.captions !== null && typeof raw.captions === "object" ? raw.captions : {};
|
|
270
317
|
captions.enabled = true;
|
|
271
318
|
captions.preset = o.preset;
|
|
@@ -296,6 +343,60 @@ addCloudOptions(
|
|
|
296
343
|
if (res.verdict === "needs-fixes") process.exitCode = 2;
|
|
297
344
|
});
|
|
298
345
|
});
|
|
346
|
+
function collectJoinOverride(raw, acc = []) {
|
|
347
|
+
const m = raw.match(/^(\d+):(\d+(?:\.\d+)?)$/);
|
|
348
|
+
if (!m) {
|
|
349
|
+
throw new Error(`--join expects <index>:<ms>, e.g. --join 3:40 (got "${raw}")`);
|
|
350
|
+
}
|
|
351
|
+
acc.push({ index: Number(m[1]), crossfadeMs: Number(m[2]) });
|
|
352
|
+
return acc;
|
|
353
|
+
}
|
|
354
|
+
addCloudOptions(
|
|
355
|
+
program.command("smooth-joins").description(
|
|
356
|
+
"Apply an equal-power audio crossfade to EVERY join (Premiere's Constant Power transition). Optional \u2014 renders are unaffected unless you run it"
|
|
357
|
+
).option("--job-dir <path>", "Job directory (default: cwd)").option("--crossfade-ms <ms>", "Transition length at every join (default: 130 \u2014 Premiere's 4 frames); 0 disables").option("--curve <name>", "equal-power (default) or linear").option("--max-ms <ms>", "Ceiling on any single join").option(
|
|
358
|
+
"--join <index:ms>",
|
|
359
|
+
"Per-join override, repeatable (e.g. --join 3:40 --join 7:220)",
|
|
360
|
+
collectJoinOverride
|
|
361
|
+
).option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
362
|
+
).action(async (o) => {
|
|
363
|
+
await withCloudErrors(!!o.json, async () => {
|
|
364
|
+
if (o.curve !== void 0 && o.curve !== "equal-power" && o.curve !== "linear") {
|
|
365
|
+
throw new Error(`--curve must be "equal-power" or "linear" (got "${o.curve}")`);
|
|
366
|
+
}
|
|
367
|
+
const jobDir = jobDirOf(o);
|
|
368
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
369
|
+
const res = await smoothJoinsCloud(client, jobDir, {
|
|
370
|
+
crossfadeMs: num(o.crossfadeMs),
|
|
371
|
+
...o.curve !== void 0 ? { curve: o.curve } : {},
|
|
372
|
+
maxMs: num(o.maxMs),
|
|
373
|
+
...Array.isArray(o.join) ? { overrides: o.join } : {},
|
|
374
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
375
|
+
verbose: !o.json
|
|
376
|
+
});
|
|
377
|
+
if (o.json) {
|
|
378
|
+
console.log(
|
|
379
|
+
JSON.stringify(
|
|
380
|
+
{
|
|
381
|
+
ok: true,
|
|
382
|
+
run_id: res.runId,
|
|
383
|
+
report: res.reportPath,
|
|
384
|
+
total_duration_s: res.totalDurationS,
|
|
385
|
+
joins: res.joins
|
|
386
|
+
},
|
|
387
|
+
null,
|
|
388
|
+
2
|
|
389
|
+
)
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
console.log(`smooth-joins: ${res.joins.length} join(s), ${res.clamped} clamped by available handle`);
|
|
393
|
+
for (const j of res.joins.filter((x) => x.clamped)) {
|
|
394
|
+
console.log(` join ${j.index}: ${j.applied_ms}ms (asked ${j.requested_ms}ms) \u2014 ${j.reason ?? "clamped"}`);
|
|
395
|
+
}
|
|
396
|
+
console.log(`report: ${res.reportPath}`);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
});
|
|
299
400
|
var qaCmd = program.command("qa").description("Cloud QC: server-rendered filmstrip frames, waveform (word-timing sidecar) + the combined inspect composite; windows in source seconds, or plan-addressed via --join / --out-start");
|
|
300
401
|
function framesWindows(jobDir, o, joinMode = "split") {
|
|
301
402
|
const start = num(o.start);
|
|
@@ -457,7 +558,7 @@ addCloudOptions(
|
|
|
457
558
|
} else {
|
|
458
559
|
console.log("no active review round");
|
|
459
560
|
}
|
|
460
|
-
console.log(`timeline.json: ${res.wroteTimeline ? "written" :
|
|
561
|
+
console.log(`timeline.json: ${res.wroteTimeline ? "written" : existsSync2(timelineJsonPath2(jobDir)) ? "kept (already present locally)" : "not provided"}`);
|
|
461
562
|
if (res.downloadedSource) console.log(`source: ${res.downloadedSource}`);
|
|
462
563
|
}
|
|
463
564
|
});
|
|
@@ -516,11 +617,11 @@ addCloudOptions(
|
|
|
516
617
|
const jobDir = jobDirOf(o);
|
|
517
618
|
jobConfig(o, jobDir, { requireVideoId: false });
|
|
518
619
|
const path = instructionPath2(jobDir);
|
|
519
|
-
if (!
|
|
520
|
-
if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content:
|
|
620
|
+
if (!existsSync2(path)) throw new Error(`no instruction found at ${path} \u2014 run \`video-editing review pull\` first`);
|
|
621
|
+
if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content: readFileSync2(path, "utf8") }, null, 2));
|
|
521
622
|
else {
|
|
522
623
|
console.log(`instruction: ${path}`);
|
|
523
|
-
process.stdout.write(
|
|
624
|
+
process.stdout.write(readFileSync2(path, "utf8"));
|
|
524
625
|
}
|
|
525
626
|
});
|
|
526
627
|
});
|
|
@@ -625,8 +726,8 @@ addCloudOptions(
|
|
|
625
726
|
jobConfig(o, jobDir, { requireVideoId: false });
|
|
626
727
|
const job = readJobFile(jobDir);
|
|
627
728
|
const artifacts = {};
|
|
628
|
-
if (job) artifacts[`transcripts/${job.stem}.json`] =
|
|
629
|
-
for (const f of STATUS_FILES) artifacts[f] =
|
|
729
|
+
if (job) artifacts[`transcripts/${job.stem}.json`] = existsSync2(join2(jobDir, "transcripts", `${job.stem}.json`));
|
|
730
|
+
for (const f of STATUS_FILES) artifacts[f] = existsSync2(join2(jobDir, f));
|
|
630
731
|
if (o.json) {
|
|
631
732
|
console.log(JSON.stringify({ ok: true, job_dir: jobDir, job: job ?? null, artifacts }, null, 2));
|
|
632
733
|
} else {
|
package/dist/index.d.ts
CHANGED
|
@@ -3331,6 +3331,17 @@ declare class CloudClient {
|
|
|
3331
3331
|
}): Promise<{
|
|
3332
3332
|
runId: string;
|
|
3333
3333
|
}>;
|
|
3334
|
+
smoothJoinsStart(body?: {
|
|
3335
|
+
crossfade_ms?: number;
|
|
3336
|
+
curve?: string;
|
|
3337
|
+
max_ms?: number;
|
|
3338
|
+
joins?: {
|
|
3339
|
+
index: number;
|
|
3340
|
+
crossfade_ms: number;
|
|
3341
|
+
}[];
|
|
3342
|
+
}): Promise<{
|
|
3343
|
+
runId: string;
|
|
3344
|
+
}>;
|
|
3334
3345
|
/** GET /runs/{id} — run status + events. */
|
|
3335
3346
|
getRun(runId: string): Promise<RunState>;
|
|
3336
3347
|
/** POST /videos/{id}/compile → {ok, plan, legacy_edl, diagnostics, uses}. */
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "video-editing",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Pure-API cloud CLI + client for editing talking-head reels with clipready: upload once, then transcription, timeline authoring, compile, render, verify and QA all run in the cloud. Node ≥ 20 + an API key — no ffmpeg.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -118,6 +118,12 @@ never work around it.
|
|
|
118
118
|
word-timing sidecar JSON). Read the PNGs to adjudicate.
|
|
119
119
|
8. **Final** — `video-editing render --mode final --job-dir "<job>"`.
|
|
120
120
|
|
|
121
|
+
**Optional, any time after compile:** if joins sound clicky, chopped, or gappy,
|
|
122
|
+
run `video-editing smooth-joins --job-dir "<job>"` to apply an equal-power
|
|
123
|
+
audio crossfade to every cut, then re-render. It is NOT part of the sequence
|
|
124
|
+
above — renders are unaffected unless you run it, and it must be re-run after
|
|
125
|
+
each recompile you want it applied to. See `references/audio-joins.md`.
|
|
126
|
+
|
|
121
127
|
## Your editorial brief arrives from the cloud
|
|
122
128
|
|
|
123
129
|
The deep editorial brief — retake/keep rules, pause tightening, cold-open,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Smoothing the audio at cuts (`smooth-joins`)
|
|
2
|
+
|
|
3
|
+
**Optional.** Nothing here runs as part of a render. If you never call it,
|
|
4
|
+
renders behave exactly as they always have.
|
|
5
|
+
|
|
6
|
+
This is the Premiere move of selecting the whole timeline and hitting *Apply
|
|
7
|
+
Audio Transition*: the picture stays a hard cut, and every join gets a short
|
|
8
|
+
**Constant Power** (equal-power) audio crossfade built from the padding the cut
|
|
9
|
+
threw away. One invocation treats every join — you do not opt in per cut.
|
|
10
|
+
|
|
11
|
+
## Why you would run it
|
|
12
|
+
|
|
13
|
+
By default each kept range is extracted with a 30 ms fade in *and* out and the
|
|
14
|
+
clips are butt-joined, so every cut has a brief hole down to silence. Depending
|
|
15
|
+
on where the cut landed that reads as a click, a clipped syllable, or a gap of
|
|
16
|
+
dead air. The crossfade turns each one into a quick natural dip instead, and
|
|
17
|
+
because it is centred on the cut the total duration does not change.
|
|
18
|
+
|
|
19
|
+
Reach for it when a verify pass or a listen flags clicks, popping, or chopped
|
|
20
|
+
word endings at joins — and on any edit with a lot of tight desilence trims,
|
|
21
|
+
where joins are most frequent.
|
|
22
|
+
|
|
23
|
+
Requires a compiled timeline (run `timeline compile` first) and an ingested
|
|
24
|
+
source. Exits non-zero with `no_edl` if you run it too early.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
video-editing smooth-joins --job-dir "<job>" # 130ms everywhere
|
|
30
|
+
video-editing smooth-joins --crossfade-ms 90 --job-dir "<job>"
|
|
31
|
+
video-editing smooth-joins --join 3:40 --join 7:220 --job-dir "<job>"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Flag | Default | Meaning |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `--crossfade-ms <ms>` | `130` | Transition length at every join (Premiere's 4 frames). `0` disables. |
|
|
37
|
+
| `--curve equal-power\|linear` | `equal-power` | Fade shape. |
|
|
38
|
+
| `--max-ms <ms>` | `500` | Ceiling on any single join. |
|
|
39
|
+
| `--join <index>:<ms>` | — | Override one boundary. Repeatable. |
|
|
40
|
+
| `--json` | — | Machine-readable output. |
|
|
41
|
+
|
|
42
|
+
**Join `i` is the boundary between segment `i` and segment `i + 1`**, so an
|
|
43
|
+
N-segment edit has N−1 joins. Same indexing `verify` reports its join findings
|
|
44
|
+
against, so a verify finding at join 5 is `--join 5:<ms>` here.
|
|
45
|
+
|
|
46
|
+
Keep `--curve equal-power` unless you have a reason: it is the only curve that
|
|
47
|
+
holds loudness flat through the transition (`sin² + cos² = 1`). `linear` dips
|
|
48
|
+
~3 dB in the middle of every join.
|
|
49
|
+
|
|
50
|
+
Per-join overrides are for shaping, not rescue — `--join 3:40` to keep a hard
|
|
51
|
+
beat change tight, `--join 7:220` to let a take change breathe, `--join 9:0` to
|
|
52
|
+
leave one boundary exactly as it was.
|
|
53
|
+
|
|
54
|
+
## Reading the result
|
|
55
|
+
|
|
56
|
+
Writes `audio/joined.m4a` (the rebuilt track) and `audio/joined.json`:
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"edl_sha256": "…",
|
|
61
|
+
"params": { "crossfade_ms": 130, "curve": "equal-power", "max_ms": 500 },
|
|
62
|
+
"total_duration_s": 47.213,
|
|
63
|
+
"segments": 42,
|
|
64
|
+
"joins": [
|
|
65
|
+
{ "index": 0, "requested_ms": 130, "applied_ms": 130, "handle_ms": 1840, "clamped": false },
|
|
66
|
+
{ "index": 1, "requested_ms": 130, "applied_ms": 52, "handle_ms": 52, "clamped": true,
|
|
67
|
+
"reason": "limited by the gap the cut removed" }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`applied_ms` is what the join actually got; `handle_ms` is the longest
|
|
73
|
+
crossfade the surrounding material could support. **A crossfade can only borrow
|
|
74
|
+
material the cut removed**, so a tight desilence trim that only took out 50 ms
|
|
75
|
+
can only give you a 50 ms transition — it will come back `clamped` no matter
|
|
76
|
+
what you request. That is expected and still sounds clean; 50 ms of same-speaker
|
|
77
|
+
room tone is plenty. Joins between different takes have seconds of handle and
|
|
78
|
+
always get the full request.
|
|
79
|
+
|
|
80
|
+
Read the report before re-running: if a join you care about came back clamped
|
|
81
|
+
with `"limited by the gap the cut removed"`, asking for more milliseconds will
|
|
82
|
+
change nothing. Widen the cut range in `timeline.json` and recompile instead.
|
|
83
|
+
|
|
84
|
+
## How it interacts with rendering
|
|
85
|
+
|
|
86
|
+
Renders **consume** the artifact, they never trigger it:
|
|
87
|
+
|
|
88
|
+
- Run `smooth-joins`, then render → the render uses the crossfaded audio.
|
|
89
|
+
- Render without running it → unchanged from today.
|
|
90
|
+
- Recompile the timeline, then render → the artifact no longer matches the
|
|
91
|
+
edit and is ignored (it is keyed to the EDL). Re-run `smooth-joins`.
|
|
92
|
+
|
|
93
|
+
So the order is: `timeline compile` → `smooth-joins` → render. Re-running the
|
|
94
|
+
command is cheap; re-run it after every recompile you want it applied to.
|
|
@@ -98,6 +98,16 @@ support transparently fall back to a single retried upload.
|
|
|
98
98
|
pause-tightening joins), adjudicate against `flags.txt` instead of blindly
|
|
99
99
|
recutting.
|
|
100
100
|
|
|
101
|
+
- **`smooth-joins [--crossfade-ms <ms>] [--curve equal-power|linear] [--max-ms <ms>] [--join <index>:<ms>]... --job-dir <dir> [--json]`**
|
|
102
|
+
**Optional.** Applies a Constant Power (equal-power) audio crossfade to
|
|
103
|
+
**every** join — Premiere's "select the timeline, apply an audio transition"
|
|
104
|
+
move. Picture stays a hard cut. Default 130 ms (4 frames); `--join 3:40`
|
|
105
|
+
overrides a single boundary, repeatable; `0` disables one. Writes
|
|
106
|
+
`audio/joined.m4a` + an `audio/joined.json` report of what each join
|
|
107
|
+
actually got after clamping. Renders consume the result only if it matches
|
|
108
|
+
the current compile — re-run after every recompile. Full detail:
|
|
109
|
+
`references/audio-joins.md`.
|
|
110
|
+
|
|
101
111
|
- **`qa frames (--start <s> --end <s> | --join <i> [--span 2] | --out-start <s> --out-end <s>) [--cols <n>] [--interval <s>] --job-dir <dir> [--json]`**
|
|
102
112
|
**Server-rendered** filmstrip PNG(s) → `<job>/qa/frames_<start>_<end>.png`.
|
|
103
113
|
Three addressing modes (exactly one): `--start/--end` in SOURCE seconds;
|