ossclip 0.1.30 → 0.1.31

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-DfTreNoE.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-C537KpNT.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-Bx2VQLP8.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.30",
3
+ "version": "0.1.31",
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/core": "0.1.30",
40
- "@ossclip/renderer": "0.1.30",
41
- "@ossclip/scenes": "0.1.30"
39
+ "@ossclip/core": "0.1.31",
40
+ "@ossclip/renderer": "0.1.31",
41
+ "@ossclip/scenes": "0.1.31"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/edit.ts CHANGED
@@ -369,6 +369,11 @@ export async function startEditServer(
369
369
  * file behind this URL). */
370
370
  const coverImageUrl = (image: string | null): string | null =>
371
371
  image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
372
+ /** Where the Preview button's one-off render lands (handoff-cover-panel
373
+ * §3): a fixed name IN the workdir — never a client-named path, and never
374
+ * the canonical `.cover.jpg` a real Apply owns. Each preview overwrites the
375
+ * last; the file is scratch, like the frame-sampler's cache. */
376
+ const coverPreviewPath = (): string => join(workdir!, "cover-preview.jpg");
372
377
 
373
378
  // ---- Portrait override (editor face swap, 2026-08-17) -------------------
374
379
  // A per-project `portrait-override.<ext>` in the workdir that outranks the
@@ -1193,6 +1198,27 @@ export async function startEditServer(
1193
1198
  // throw for want of a destination, and the panel says so up front
1194
1199
  // rather than after a click.
1195
1200
  const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
1201
+ // The finished mp4's OWN span set — the RESOLVED cutlist, never the
1202
+ // proposal /api/cleanup serves: the panel converts the playhead
1203
+ // between the output clock and the source clock
1204
+ // (handoff-cover-panel §1), and a proposal the user's vetoes already
1205
+ // changed is the wrong ruler. Lenient per-span parse, [] on a
1206
+ // missing or corrupt production.json — a cover panel that cannot
1207
+ // convert must still open.
1208
+ let coverCutlist: Segment[] = [];
1209
+ try {
1210
+ const production = JSON.parse(
1211
+ await readFile(join(workdir, "production.json"), "utf8"),
1212
+ ) as { cutlist?: unknown };
1213
+ if (Array.isArray(production.cutlist)) {
1214
+ coverCutlist = production.cutlist.flatMap((s) => {
1215
+ const p = SegmentSchema.safeParse(s);
1216
+ return p.success ? [p.data] : [];
1217
+ });
1218
+ }
1219
+ } catch {
1220
+ // degrade — same as /api/cleanup
1221
+ }
1196
1222
  return send(200, {
1197
1223
  status: outPath === null ? "unavailable" : "ready",
1198
1224
  ...(outPath === null
@@ -1203,6 +1229,7 @@ export async function startEditServer(
1203
1229
  provenance,
1204
1230
  outPath,
1205
1231
  imageUrl: coverImageUrl(image),
1232
+ cutlist: coverCutlist,
1206
1233
  });
1207
1234
  }
1208
1235
 
@@ -1283,6 +1310,78 @@ export async function startEditServer(
1283
1310
  }
1284
1311
  }
1285
1312
 
1313
+ if (url.pathname === "/api/cover/preview-image" && req.method === "GET") {
1314
+ if (!workdir) return send(409, { error: "no workdir open" });
1315
+ const image = coverPreviewPath();
1316
+ if (!existsSync(image)) return send(404, { error: "no preview image" });
1317
+ // /api/cover/image's exact posture, for its exact reason: every
1318
+ // preview REPLACES the file behind a URL the panel busts with ?ts,
1319
+ // and a cached 200 would show the old preview against the new ts.
1320
+ const bytes = await readFile(image);
1321
+ res.writeHead(200, {
1322
+ "content-type": "image/jpeg",
1323
+ "cache-control": "no-store",
1324
+ "content-length": String(bytes.length),
1325
+ });
1326
+ res.end(bytes);
1327
+ return;
1328
+ }
1329
+
1330
+ if (url.pathname === "/api/cover/preview" && req.method === "POST") {
1331
+ // The regenerate handler above, verbatim, up to the outPath: a
1332
+ // preview is a full cover render — it boots the same headless
1333
+ // browser (hence the same coverBusy gate), takes the same three
1334
+ // steerable values and nothing else, and fails with the same
1335
+ // 200-with-ok:false verbatim-message posture.
1336
+ if (!workdir) return send(409, { error: "no workdir open" });
1337
+ if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
1338
+ const chunks: Buffer[] = [];
1339
+ for await (const c of req) chunks.push(c as Buffer);
1340
+ const parsed = z
1341
+ .object({
1342
+ text: z.string().optional(),
1343
+ atSec: CoverAtSecondsSchema.optional(),
1344
+ from: CoverFromSchema.optional(),
1345
+ })
1346
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1347
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1348
+ coverBusy = true;
1349
+ try {
1350
+ const notes: string[] = [];
1351
+ await regenerateCover(
1352
+ workdir,
1353
+ {
1354
+ text: parsed.data.text,
1355
+ atSec: parsed.data.atSec,
1356
+ from: parsed.data.from,
1357
+ // The ONE-OFF path, derived HERE and never from the body (the
1358
+ // regenerate handler's comment owns why). Same machinery as
1359
+ // `ossclip cover --out` (handoff-cover-panel §3): the
1360
+ // canonical cover is untouched, provenance updates to
1361
+ // describe the previewed frame — which is what makes a
1362
+ // follow-up blank-field Apply adopt it via the cheap path,
1363
+ // and the one-off note riding back is the panel's disclosure
1364
+ // of exactly that.
1365
+ outPath: coverPreviewPath(),
1366
+ },
1367
+ { renderCover: opts.renderCover, log: (line) => notes.push(line) },
1368
+ );
1369
+ return send(200, {
1370
+ ok: true,
1371
+ notes,
1372
+ // coverImageUrl's mtime rule, aimed at the preview file: the
1373
+ // URL changes exactly when the file does.
1374
+ previewImageUrl: `/api/cover/preview-image?ts=${Math.round(
1375
+ statSync(coverPreviewPath()).mtimeMs,
1376
+ )}`,
1377
+ });
1378
+ } catch (err) {
1379
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1380
+ } finally {
1381
+ coverBusy = false;
1382
+ }
1383
+ }
1384
+
1286
1385
  if (url.pathname.startsWith("/media/")) {
1287
1386
  if (!workdir) return send(409, { error: "no workdir open" });
1288
1387
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
package/src/produce.ts CHANGED
@@ -23,6 +23,9 @@ import {
23
23
  analyze,
24
24
  applyOverrides,
25
25
  applyRepairs,
26
+ isParkedOverrideKey,
27
+ parkedOverrideBaseKey,
28
+ remapSceneOverrides,
26
29
  assembleScenes,
27
30
  buildCaptionLines,
28
31
  buildCutlist,
@@ -1894,6 +1897,20 @@ export function layoutSlotAspects(frame: {
1894
1897
  });
1895
1898
  }
1896
1899
 
1900
+ /**
1901
+ * One orphaned scene edit, one honest sentence. A parked key (`…#orphaned`,
1902
+ * handoff-edit-anchoring) matches no cue BY DESIGN, so it surfaces in
1903
+ * `applyOverrides`' orphan list on every run — and "dropped" would tell the
1904
+ * user an edit is gone while the doc still holds it, anchor intact, waiting
1905
+ * for a plan that has its words again. Pure so both sentences are testable
1906
+ * without a produce run.
1907
+ */
1908
+ export function orphanEditLine(id: string): string {
1909
+ return isParkedOverrideKey(id)
1910
+ ? ` ⚠ edit for ${parkedOverrideBaseKey(id)} is parked — its words are not in this plan`
1911
+ : ` ⚠ edit for ${id} dropped — the plan no longer has that scene`;
1912
+ }
1913
+
1897
1914
  async function preflight(bin: string, hint: string): Promise<void> {
1898
1915
  try {
1899
1916
  await run(bin, ["-version"], { allowNonZero: true });
@@ -3180,6 +3197,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3180
3197
  const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
3181
3198
  for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
3182
3199
 
3200
+ // Re-key scene edits whose positional id no longer means the moment they
3201
+ // were made on (handoff-edit-anchoring; §137 is the caption precedent — in
3202
+ // the field, scene-4 was a TerminalMock in one plan and a FlowDiagram in
3203
+ // the next, and the user's edit landed on the impostor). Placed HERE,
3204
+ // before ANY consumer of `overrideDoc.scenes`: the first `applyOverrides`
3205
+ // pass, `splitThenDropHidden` and the pinned-timing reclamp all join by id,
3206
+ // and a stale key at any of them bakes the misapply this plan exists to
3207
+ // kill — a `hidden` on a renumbered scene would hide the impostor, not the
3208
+ // moved moment. `assembled` is enough for the match: the remap reads only
3209
+ // anchored graphic cues, and take ids (which don't exist until the fill)
3210
+ // carry no anchors and are left untouched by design. Notes are non-empty
3211
+ // exactly when the doc changed, which is what earns the write-back its
3212
+ // turn at the sanctioned write below.
3213
+ const sceneRemap = remapSceneOverrides(overrideDoc, assembled);
3214
+ overrideDoc = sceneRemap.doc;
3215
+ for (const n of sceneRemap.notes) console.log(` ▸ ${n}`);
3216
+ const sceneKeysRemapped = sceneRemap.notes.length > 0;
3217
+
3183
3218
  // ---- Framing plan → props (2026-08-16 incident) --------------------------
3184
3219
  // The plan used to be BAKED here: every window cropped, scaled and
3185
3220
  // re-encoded into a content-<hash>.mp4 that replaced the source for the
@@ -3438,7 +3473,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3438
3473
  console.log(`▸ applied your edits to ${editedCount - orphans.length - hiddenIds.length} scene(s)`);
3439
3474
  }
3440
3475
  for (const id of orphans) {
3441
- console.log(` ⚠ edit for ${id} dropped — the plan no longer has that scene`);
3476
+ console.log(orphanEditLine(id));
3442
3477
  }
3443
3478
 
3444
3479
  const graphicCues = sceneCues.filter((c) => c.kind !== "plain");
@@ -4295,7 +4330,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4295
4330
  // re-report "the cut removed it" on every later run. It does NOT spend the
4296
4331
  // `.bak` — retiring a redundant key is not the cut re-anchoring the backup
4297
4332
  // exists to survive.
4298
- if (cutResult.changed || captionKeysReanchored || hidesPruned) {
4333
+ // `sceneKeysRemapped` joins for the caption-migration reason: a scene edit
4334
+ // that followed its anchor to a new id (handoff-edit-anchoring) is keyed
4335
+ // right in memory but wrong on disk, and every re-keyed entry was just
4336
+ // printed by name. It does NOT spend the `.bak` — the remap moves keys, it
4337
+ // rewrites no values a user would need to recover.
4338
+ if (cutResult.changed || captionKeysReanchored || hidesPruned || sceneKeysRemapped) {
4299
4339
  await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
4300
4340
  console.log(overridesWriteLine(cutResult.changed));
4301
4341
  }