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.
@@ -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-W4IIL2T2.js";
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(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
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 (!existsSync(tlPath)) throw new Error(`timeline not found: ${tlPath}`);
268
- const raw = JSON.parse(readFileSync(tlPath, "utf8"));
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" : existsSync(timelineJsonPath2(jobDir)) ? "kept (already present locally)" : "not provided"}`);
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 (!existsSync(path)) throw new Error(`no instruction found at ${path} \u2014 run \`video-editing review pull\` first`);
520
- if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content: readFileSync(path, "utf8") }, null, 2));
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(readFileSync(path, "utf8"));
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`] = existsSync(join(jobDir, "transcripts", `${job.stem}.json`));
629
- for (const f of STATUS_FILES) artifacts[f] = existsSync(join(jobDir, 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
@@ -112,7 +112,7 @@ import {
112
112
  waveformPngPath,
113
113
  waveformWordsPath,
114
114
  writeJobFile
115
- } from "./chunk-W4IIL2T2.js";
115
+ } from "./chunk-2OM7WV4W.js";
116
116
 
117
117
  // src/timeline-v2/anchors.ts
118
118
  function normalizeToken(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "video-editing",
3
- "version": "3.0.0",
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;