ossclip 0.1.20 → 0.1.22

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-B44I-xE4.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-MLGz89mM.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-ChRVBVLj.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/scenes": "0.1.20",
40
- "@ossclip/renderer": "0.1.20",
41
- "@ossclip/core": "0.1.20"
39
+ "@ossclip/renderer": "0.1.22",
40
+ "@ossclip/scenes": "0.1.22",
41
+ "@ossclip/core": "0.1.22"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/analyze.ts ADDED
@@ -0,0 +1,230 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { z } from "zod/v4";
4
+ import {
5
+ ProductionSchema,
6
+ buildFcpxmlMarkers,
7
+ buildPremiereProject,
8
+ buildPremiereXmlMarkers,
9
+ buildResolveMarkerEdl,
10
+ keptPauses,
11
+ type CleanupLevel,
12
+ } from "@ossclip/core";
13
+ import { produce } from "./produce";
14
+ import type { PhaseTimings } from "./phase-timing";
15
+
16
+ /**
17
+ * `ossclip analyze` (next-directions §2–3; design doc
18
+ * 2026-08-12-analyse-fcpxml-export-design.md): the analyzer without the
19
+ * renderer. §140 measured the render at 85% of wall time on two machines —
20
+ * this command is the product of skipping it: the same pipeline up to the
21
+ * cut report (no LLM, no Remotion), plus an export file an editor's own NLE
22
+ * can review. Markers, not applied cuts, by design — see the exporter.
23
+ */
24
+
25
+ /**
26
+ * zod enum, not a string: a typo'd `--format fcpxmll` must error naming the
27
+ * choice, never fall back to a default that silently writes the wrong file
28
+ * (CLAUDE.md's `--source-fit containn` rule, verbatim).
29
+ *
30
+ * `resolve-edl`, not `edl` (§142): it is Resolve's marker-EDL dialect —
31
+ * markers with colours, for `Timeline → Import → Timeline Markers from EDL`
32
+ * — not a cut EDL. A future cut EDL gets its own name; "edl" meaning either
33
+ * would be a permanent ambiguity. It exists because Resolve's FCPXML import
34
+ * silently drops clip markers (field-verified the day fcpxml shipped);
35
+ * Premiere reads the fcpxml markers fine.
36
+ */
37
+ export const ExportFormatSchema = z.enum([
38
+ "fcpxml",
39
+ "resolve-edl",
40
+ "premiere-xml",
41
+ // The pre-cut project: markers annotate an untouched take; this one applies
42
+ // the cutlist and carries the render's camera motion as editable keyframes
43
+ // (plan 2026-08-13). A distinct name, not a flag on premiere-xml — the two
44
+ // answer different questions ("review my cuts" vs "hand me the edit").
45
+ "premiere-project",
46
+ ]);
47
+ export type ExportFormat = z.infer<typeof ExportFormatSchema>;
48
+
49
+ /** The FILE extension per format — "demo.resolve-edl" would import nowhere. */
50
+ const FORMAT_EXTENSIONS: Record<ExportFormat, string> = {
51
+ fcpxml: "fcpxml",
52
+ "resolve-edl": "edl",
53
+ "premiere-xml": "xml",
54
+ // NOT bare "xml": the marker format above already owns `<input>.xml`, and
55
+ // an editor who exported markers yesterday and the project today would
56
+ // silently clobber the file they sent their editor. Distinct default,
57
+ // same importable extension.
58
+ "premiere-project": "project.xml",
59
+ };
60
+
61
+ /**
62
+ * The slice of `render-props.json` the project export needs — SELECTIVE on
63
+ * purpose, and parsed rather than cast (CLAUDE.md): the file is user-visible
64
+ * and hand-editable, so a truncated or tweaked one must error here with a
65
+ * field name, not export a project with NaN trims. `srcStart` and the rest
66
+ * of a caption word are deliberately not asked for: the SRT only needs text
67
+ * and output-time bounds, and legacy files predate `srcStart` (§137).
68
+ */
69
+ export const RenderPropsExportSchema = z.object({
70
+ spans: z.array(
71
+ z.object({
72
+ srcIn: z.number(),
73
+ srcOut: z.number(),
74
+ outIn: z.number(),
75
+ outOut: z.number(),
76
+ }),
77
+ ),
78
+ captionLines: z.array(
79
+ z.object({
80
+ words: z.array(z.object({ text: z.string() })),
81
+ start: z.number(),
82
+ end: z.number(),
83
+ }),
84
+ ),
85
+ zoomPlan: z.array(
86
+ z.object({
87
+ startSec: z.number(),
88
+ endSec: z.number(),
89
+ from: z.number(),
90
+ to: z.number(),
91
+ }),
92
+ ),
93
+ // Written only when the camera is OFF (produce's absent-means-default
94
+ // contract) — absent must read as "motion on".
95
+ staticCamera: z.boolean().optional(),
96
+ });
97
+
98
+ /** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
99
+ export function defaultExportPath(input: string, format: ExportFormat): string {
100
+ return input.replace(/(\.[^.]+)?$/, `.${FORMAT_EXTENSIONS[format]}`);
101
+ }
102
+
103
+ export interface AnalyzeOptions {
104
+ cleanup: CleanupLevel;
105
+ format: ExportFormat;
106
+ out?: string;
107
+ transcript?: string;
108
+ workdir?: string;
109
+ noiseDb?: number;
110
+ whisperModel?: string;
111
+ whisperLanguage?: string;
112
+ blooperMarker?: string;
113
+ collapseRetakes?: boolean;
114
+ sort?: "name" | "mtime";
115
+ sortExplicit?: boolean;
116
+ }
117
+
118
+ export interface AnalyzeResult {
119
+ workdir: string;
120
+ outPath: string;
121
+ /** The SRT sidecar — present only for `premiere-project` (two-file format). */
122
+ srtPath?: string;
123
+ /** Suggested-cut markers — one per remove segment. */
124
+ markerCount: number;
125
+ /** Informational kept-pause markers (§142 round 2) — counted separately
126
+ * so the CLI and telemetry can say which is which. */
127
+ pauseCount: number;
128
+ sourceDurationSec: number;
129
+ phaseTimings: PhaseTimings;
130
+ }
131
+
132
+ /**
133
+ * The I/O glue: run the existing no-render pipeline, read back the
134
+ * `production.json` it wrote, hand it to the pure exporter, write the file.
135
+ * The read-back goes through `ProductionSchema.parse` even though this very
136
+ * run just wrote it — the file is user-visible and hand-editable, and a
137
+ * truncated or tweaked one must error here, not export garbage markers.
138
+ */
139
+ export async function runAnalyze(
140
+ inputArg: string,
141
+ opts: AnalyzeOptions,
142
+ ): Promise<AnalyzeResult> {
143
+ const result = await produce(inputArg, {
144
+ cleanup: opts.cleanup,
145
+ transcript: opts.transcript,
146
+ render: false,
147
+ mezzanine: false,
148
+ workdir: opts.workdir,
149
+ noiseDb: opts.noiseDb,
150
+ whisperModel: opts.whisperModel,
151
+ whisperLanguage: opts.whisperLanguage,
152
+ blooperMarker: opts.blooperMarker,
153
+ collapseRetakes: opts.collapseRetakes,
154
+ sort: opts.sort,
155
+ sortExplicit: opts.sortExplicit,
156
+ cover: false,
157
+ });
158
+ const production = ProductionSchema.parse(
159
+ JSON.parse(await readFile(join(result.workdir, "production.json"), "utf8")),
160
+ );
161
+ const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
162
+ const pauseCount = keptPauses(production).length;
163
+ const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
164
+
165
+ if (opts.format === "premiere-project") {
166
+ // The project export reads render-props.json, not just production.json:
167
+ // spans/captions/zoom are the RENDER's own inputs, so the exported
168
+ // project is the render, not a re-derivation of it. Same parse rule as
169
+ // the production read-back above — the file is hand-editable.
170
+ const props = RenderPropsExportSchema.parse(
171
+ JSON.parse(await readFile(join(result.workdir, "render-props.json"), "utf8")),
172
+ );
173
+ const { xml, srt } = buildPremiereProject({
174
+ production,
175
+ spans: props.spans,
176
+ captionLines: props.captionLines,
177
+ zoomPlan: props.zoomPlan,
178
+ staticCamera: props.staticCamera,
179
+ // The OUTPUT frame — production.render already carries --aspect.
180
+ frame: { width: production.render.width, height: production.render.height },
181
+ });
182
+ // Same basename, .srt — beside the xml so the pair travels together.
183
+ const srtPath = outPath.replace(/(\.[^.]+)?$/, ".srt");
184
+ await writeFile(outPath, xml);
185
+ await writeFile(srtPath, srt);
186
+ console.log(
187
+ `✓ premiere-project → ${outPath} + ${srtPath} ` +
188
+ `(cuts applied, camera motion as Basic Motion keyframes — in Premiere: ` +
189
+ `File → Import the .xml; then import the .srt onto the timeline for captions)`,
190
+ );
191
+ return {
192
+ workdir: result.workdir,
193
+ outPath,
194
+ srtPath,
195
+ markerCount,
196
+ pauseCount,
197
+ sourceDurationSec: result.sourceDurationSec,
198
+ phaseTimings: result.phaseTimings,
199
+ };
200
+ }
201
+
202
+ // §142, learned the hard way in one field-test hour: each NLE gets its OWN
203
+ // dialect. Premiere rejects modern fcpxml outright, Resolve's fcpxml import
204
+ // silently drops markers — so fcpxml is for actual Final Cut Pro.
205
+ const content =
206
+ opts.format === "resolve-edl"
207
+ ? buildResolveMarkerEdl(production)
208
+ : opts.format === "premiere-xml"
209
+ ? buildPremiereXmlMarkers(production)
210
+ : buildFcpxmlMarkers(production);
211
+ await writeFile(outPath, content);
212
+ const detail =
213
+ opts.format === "resolve-edl"
214
+ ? "in Resolve: Media Pool → right-click your timeline → Timelines → Import → Timeline Markers from EDL"
215
+ : opts.format === "premiere-xml"
216
+ ? "in Premiere: File → Import, pick the .xml, relink media if offline"
217
+ : "for Final Cut Pro; Premiere needs --format premiere-xml, Resolve --format resolve-edl";
218
+ console.log(
219
+ `✓ ${opts.format} → ${outPath} (${markerCount} cut marker${markerCount === 1 ? "" : "s"}` +
220
+ `${pauseCount > 0 ? ` + ${pauseCount} kept-pause marker${pauseCount === 1 ? "" : "s"}` : ""} — ${detail})`,
221
+ );
222
+ return {
223
+ workdir: result.workdir,
224
+ outPath,
225
+ markerCount,
226
+ pauseCount,
227
+ sourceDurationSec: result.sourceDurationSec,
228
+ phaseTimings: result.phaseTimings,
229
+ };
230
+ }
package/src/edit.ts CHANGED
@@ -289,6 +289,20 @@ export async function startEditServer(
289
289
  videoFileName: renderProps.videoFileName,
290
290
  // Whether the Render button has a recorded invocation to replay.
291
291
  canRender: existsSync(commandPath()),
292
+ defaultOutPath: (() => {
293
+ try {
294
+ if (existsSync(commandPath())) {
295
+ const cmd = JSON.parse(readFileSync(commandPath(), "utf8")) as { args?: string[] };
296
+ if (Array.isArray(cmd.args)) {
297
+ const idx = cmd.args.findIndex((a) => a === "-o" || a === "--out");
298
+ if (idx !== -1 && cmd.args[idx + 1]) return cmd.args[idx + 1];
299
+ }
300
+ }
301
+ } catch {
302
+ // ignore
303
+ }
304
+ return undefined;
305
+ })(),
292
306
  // Recents ride along here too, so the top bar's Open picker has
293
307
  // a list without a second endpoint.
294
308
  recent: await readRecentProjects(opts.recentDir),
@@ -345,6 +359,28 @@ export async function startEditServer(
345
359
  return send(200, { ok: true, workdir });
346
360
  }
347
361
 
362
+ if (url.pathname === "/api/pick-save-path" && req.method === "POST") {
363
+ const chunks: Buffer[] = [];
364
+ for await (const c of req) chunks.push(c as Buffer);
365
+ let defaultPath: string | undefined;
366
+ try {
367
+ const raw = Buffer.concat(chunks).toString();
368
+ if (raw.trim()) {
369
+ const body = JSON.parse(raw) as { defaultPath?: string };
370
+ if (typeof body.defaultPath === "string") defaultPath = body.defaultPath;
371
+ }
372
+ } catch {
373
+ // ignore
374
+ }
375
+ let startLoc = defaultPath;
376
+ if (!startLoc && workdir) {
377
+ startLoc = dirname(workdir);
378
+ }
379
+ const { pickPath, livePickerDeps } = await import("./interactive/picker");
380
+ const picked = await pickPath("save", livePickerDeps(), startLoc);
381
+ return send(200, { path: picked ?? null });
382
+ }
383
+
348
384
  if (url.pathname === "/api/fs" && req.method === "GET") {
349
385
  // The picker's folder browser (R17 §83): directories only, with
350
386
  // "this one is a project" flagged. Local tool on a local loopback
@@ -394,8 +430,6 @@ export async function startEditServer(
394
430
  }
395
431
 
396
432
  if (url.pathname === "/api/render" && req.method === "POST") {
397
- // Replays ONLY the argv `produce` recorded — the request body is
398
- // never read, let alone executed.
399
433
  if (!workdir) return send(409, { error: "no workdir open" });
400
434
  if (renderChild) return send(409, { error: "a render is already running" });
401
435
  if (!existsSync(commandPath())) {
@@ -405,6 +439,20 @@ export async function startEditServer(
405
439
  "the terminal so the invocation is recorded, then Render can replay it",
406
440
  });
407
441
  }
442
+ const chunks: Buffer[] = [];
443
+ for await (const c of req) chunks.push(c as Buffer);
444
+ let customOut: string | undefined;
445
+ try {
446
+ const raw = Buffer.concat(chunks).toString();
447
+ if (raw.trim()) {
448
+ const body = JSON.parse(raw) as { out?: string };
449
+ if (typeof body.out === "string" && body.out.trim()) {
450
+ customOut = body.out.trim();
451
+ }
452
+ }
453
+ } catch {
454
+ // ignore
455
+ }
408
456
  const parsed = CommandSchema.safeParse(
409
457
  JSON.parse(await readFile(commandPath(), "utf8")),
410
458
  );
@@ -420,7 +468,19 @@ export async function startEditServer(
420
468
  // reconstruct the command that ran. A modern record — and a legacy
421
469
  // directly-typed `ossclip produce …` — already starts with it and
422
470
  // is untouched.
423
- const args = cmd.args[0] === "produce" ? cmd.args : ["produce", ...cmd.args];
471
+ let args = cmd.args[0] === "produce" ? [...cmd.args] : ["produce", ...cmd.args];
472
+ if (customOut) {
473
+ const filteredArgs: string[] = [];
474
+ for (let i = 0; i < args.length; i++) {
475
+ if (args[i] === "-o" || args[i] === "--out") {
476
+ i++;
477
+ } else {
478
+ filteredArgs.push(args[i]!);
479
+ }
480
+ }
481
+ filteredArgs.push("--out", customOut);
482
+ args = filteredArgs;
483
+ }
424
484
  renderLines = [];
425
485
  renderExit = null;
426
486
  renderStartedAt = Date.now();
@@ -13,7 +13,7 @@ import { binOnPath } from "../llm-detect";
13
13
  * this shape exists to prevent.
14
14
  */
15
15
 
16
- export type PickMode = "file" | "folder";
16
+ export type PickMode = "file" | "folder" | "save";
17
17
 
18
18
  export interface PickerDeps {
19
19
  platform: NodeJS.Platform;
@@ -86,14 +86,19 @@ export function pickerCommand(
86
86
  // the alternative and is NOT used: its mkv coverage is unconfirmed, and
87
87
  // mkv is in VIDEO_EXTENSIONS.
88
88
  const types = VIDEO_EXTENSIONS.map((e) => `"${e}"`).join(",");
89
- const clause =
90
- mode === "folder"
91
- ? 'choose folder with prompt "Pick a folder of clips"'
92
- : `choose file with prompt "Pick your video" of type {${types}}`;
93
- // startDir is always process.cwd(), never user text — but JSON.stringify
94
- // still escapes it, because AppleScript string syntax is close enough to
95
- // JSON's that this is the cheap correct thing rather than concatenation.
96
- const loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
89
+ let clause = `choose file with prompt "Pick your video" of type {${types}}`;
90
+ let loc = "";
91
+ if (mode === "folder") {
92
+ clause = 'choose folder with prompt "Pick a folder of clips"';
93
+ loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
94
+ } else if (mode === "save") {
95
+ const defaultName = startDir && !startDir.endsWith("/") ? startDir.split("/").pop()! : "video.ossclip.mp4";
96
+ const dir = startDir && !startDir.endsWith("/") ? startDir.slice(0, startDir.lastIndexOf("/")) : (startDir ?? "");
97
+ const dirClause = dir ? ` default location POSIX file ${JSON.stringify(dir)}` : "";
98
+ clause = `choose file name with prompt "Save rendered video as" default name ${JSON.stringify(defaultName)}${dirClause}`;
99
+ } else {
100
+ loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
101
+ }
97
102
  return { bin: "osascript", args: ["-e", `POSIX path of (${clause}${loc})`] };
98
103
  }
99
104
 
@@ -102,29 +107,34 @@ export function pickerCommand(
102
107
  // `powershell -Command` uses by default, and a deadlock here looks
103
108
  // exactly like a hung CLI. `powershell` (5.1, in-box) rather than
104
109
  // `pwsh`, which is not installed by default.
105
- // startDir is honoured here too, under different property names on the
106
- // two dialogs. Without it a Windows user standing in D:\shoots\raw opens
107
- // at Desktop while darwin, zenity and kdialog all land in the project
108
- // folder — the same flag, three platforms agreeing and one not.
110
+ const defaultName = startDir ? startDir.split(/[\\/]/).pop()! : "video.ossclip.mp4";
111
+ const dir = startDir ? startDir.slice(0, Math.max(startDir.lastIndexOf("\\"), startDir.lastIndexOf("/"))) : "";
109
112
  const start =
110
113
  startDir === undefined
111
114
  ? ""
112
115
  : mode === "folder"
113
- ? // FolderBrowserDialog seeds its start folder from SelectedPath —
114
- // the same property it hands the answer back in.
115
- `$d.SelectedPath = '${psQuote(startDir)}'; `
116
- : `$d.InitialDirectory = '${psQuote(startDir)}'; `;
116
+ ? `$d.SelectedPath = '${psQuote(startDir)}'; `
117
+ : mode === "save"
118
+ ? `$d.InitialDirectory = '${psQuote(dir || startDir)}'; `
119
+ : `$d.InitialDirectory = '${psQuote(startDir)}'; `;
117
120
  const script =
118
121
  mode === "folder"
119
122
  ? "Add-Type -AssemblyName System.Windows.Forms; " +
120
123
  "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " +
121
124
  start +
122
125
  "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }"
123
- : "Add-Type -AssemblyName System.Windows.Forms; " +
124
- "$d = New-Object System.Windows.Forms.OpenFileDialog; " +
125
- `$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
126
- start +
127
- "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }";
126
+ : mode === "save"
127
+ ? "Add-Type -AssemblyName System.Windows.Forms; " +
128
+ "$d = New-Object System.Windows.Forms.SaveFileDialog; " +
129
+ `$d.FileName = '${psQuote(defaultName)}'; ` +
130
+ `$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
131
+ start +
132
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }"
133
+ : "Add-Type -AssemblyName System.Windows.Forms; " +
134
+ "$d = New-Object System.Windows.Forms.OpenFileDialog; " +
135
+ `$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
136
+ start +
137
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }";
128
138
  return { bin: "powershell", args: ["-NoProfile", "-STA", "-Command", script] };
129
139
  }
130
140
 
@@ -134,13 +144,18 @@ export function pickerCommand(
134
144
  if (d.hasBin("zenity")) {
135
145
  const args = [
136
146
  "--file-selection",
137
- mode === "folder" ? "--title=Pick a folder of clips" : "--title=Pick your video",
147
+ mode === "folder"
148
+ ? "--title=Pick a folder of clips"
149
+ : mode === "save"
150
+ ? "--title=Save rendered video as"
151
+ : "--title=Pick your video",
138
152
  ];
139
153
  if (mode === "folder") args.push("--directory");
154
+ else if (mode === "save") args.push("--save", "--confirm-overwrite", `--file-filter=Video | ${globs()}`, "--file-filter=All files | *");
140
155
  else args.push(`--file-filter=Video | ${globs()}`, "--file-filter=All files | *");
141
- // The trailing slash is what makes zenity read this as a starting
142
- // DIRECTORY rather than a pre-filled filename.
143
- if (startDir !== undefined) args.push(`--filename=${startDir}/`);
156
+ if (startDir !== undefined) {
157
+ args.push(mode === "save" ? `--filename=${startDir}` : `--filename=${startDir}/`);
158
+ }
144
159
  return { bin: "zenity", args };
145
160
  }
146
161
 
@@ -149,6 +164,12 @@ export function pickerCommand(
149
164
  // filter here is an error, not an ignored argument.
150
165
  return { bin: "kdialog", args: ["--getexistingdirectory", startDir ?? "."] };
151
166
  }
167
+ if (mode === "save") {
168
+ return {
169
+ bin: "kdialog",
170
+ args: ["--getsavefilename", startDir ?? ".", `Video files(${globs()})`],
171
+ };
172
+ }
152
173
  return {
153
174
  bin: "kdialog",
154
175
  args: ["--getopenfilename", startDir ?? ".", `Video files(${globs()})`],