ossclip 0.1.29 → 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.
- package/editor-dist/assets/index-C537KpNT.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +11 -2
- package/src/edit.ts +99 -0
- package/src/produce.ts +156 -28
- package/editor-dist/assets/index-BBJKC2Z2.js +0 -164
package/editor-dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/
|
|
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/cover.ts
CHANGED
|
@@ -527,12 +527,21 @@ export function coverBannerText(args: {
|
|
|
527
527
|
* theme, which is exactly how a regenerated cover picks up a theme change for
|
|
528
528
|
* free (§corr.2). `production.json` carries neither the RESOLVED theme nor
|
|
529
529
|
* the editor's edits, which is why this is the file that gets read. */
|
|
530
|
-
const CoverRenderPropsSchema = z.object({
|
|
530
|
+
export const CoverRenderPropsSchema = z.object({
|
|
531
531
|
theme: ThemeSchema.optional(),
|
|
532
532
|
settings: z.object({ width: z.number(), height: z.number() }).optional(),
|
|
533
533
|
/** The take's whole-frame subject, for a re-pick's scoring — "screen"
|
|
534
534
|
* zeroes the face weight (scoreCandidate's 2026-08-16 incident). */
|
|
535
|
-
|
|
535
|
+
// `.nullable()`, not just `.optional()` (§154): produce writes `face: null`
|
|
536
|
+
// when the detector found nobody, and null is a MEASUREMENT — core's own
|
|
537
|
+
// schema says so ("null = no face found"). `.optional()` accepts a missing
|
|
538
|
+
// key and rejects an explicit null, so every screen recording and
|
|
539
|
+
// slides-with-voiceover project could not regenerate its cover at all.
|
|
540
|
+
// Downstream reads `renderProps.face?.subject`, which is already null-safe.
|
|
541
|
+
face: z
|
|
542
|
+
.object({ subject: z.enum(["face", "screen"]).optional() })
|
|
543
|
+
.nullable()
|
|
544
|
+
.optional(),
|
|
536
545
|
});
|
|
537
546
|
|
|
538
547
|
export interface CoverRegenerateOptions {
|
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,
|
|
@@ -347,6 +350,61 @@ export function beatSheetCacheKey(parts: {
|
|
|
347
350
|
.slice(0, 8);
|
|
348
351
|
}
|
|
349
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The beat-sheet cache keys to TRY, in priority order (§150).
|
|
355
|
+
*
|
|
356
|
+
* §143 split the cache: reads use the provider you asked for, writes file
|
|
357
|
+
* under the one that actually answered. That is right for attribution and
|
|
358
|
+
* wrong for re-runs — while agy keeps timing out, a run that asks for
|
|
359
|
+
* antigravity reads a key nothing will ever write. It re-attempts, waits out
|
|
360
|
+
* the whole print-timeout, falls back, and rewrites the key nobody reads.
|
|
361
|
+
* Every re-render therefore re-plans, the plan differs each time, and editor
|
|
362
|
+
* edits anchored to scenes the new plan no longer has are dropped — a
|
|
363
|
+
* re-render silently rewriting an approved cut (2026-08-23: "edit for
|
|
364
|
+
* scene-11 dropped — the plan no longer has that scene").
|
|
365
|
+
*
|
|
366
|
+
* So the read tries a second key, and exactly one: the provider THIS run
|
|
367
|
+
* would fall back to anyway. Serving that sheet is not a substitution — it is
|
|
368
|
+
* what this run would produce, without paying the timeout to rediscover it.
|
|
369
|
+
* Anything looser (any provider's sheet, newest file wins) would hand a
|
|
370
|
+
* claude-cli plan to someone who asked for gemini and got gemini.
|
|
371
|
+
*/
|
|
372
|
+
/**
|
|
373
|
+
* The producer stamp already on disk, or undefined (§152).
|
|
374
|
+
*
|
|
375
|
+
* Read so a run that answered NOTHING can keep it. `production.json` is
|
|
376
|
+
* rewritten on every produce, cached or not, and the stamp is rebuilt from
|
|
377
|
+
* this run's usage records — which on a cached run are empty, so it fell
|
|
378
|
+
* through to the provider we ASKED for and quietly overwrote a truthful
|
|
379
|
+
* "antigravity → claude-cli" with "antigravity". Attribution belongs to the
|
|
380
|
+
* run that produced the plan, and a cached run produced nothing.
|
|
381
|
+
*
|
|
382
|
+
* Tolerant on purpose: a missing, unreadable or stamp-less file all mean "no
|
|
383
|
+
* prior attribution", which is the same answer a first run gives.
|
|
384
|
+
*/
|
|
385
|
+
export function existingProducerStamp(work: string): Production["producer"] | undefined {
|
|
386
|
+
try {
|
|
387
|
+
const raw = JSON.parse(readFileSync(join(work, "production.json"), "utf8")) as {
|
|
388
|
+
producer?: Production["producer"];
|
|
389
|
+
};
|
|
390
|
+
return raw.producer;
|
|
391
|
+
} catch {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function beatCacheKeyCandidates(
|
|
397
|
+
parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
|
|
398
|
+
providerName: string,
|
|
399
|
+
fallbackName: string | undefined,
|
|
400
|
+
): string[] {
|
|
401
|
+
const keys = [beatSheetCacheKey({ ...parts, providerName })];
|
|
402
|
+
if (fallbackName && fallbackName !== providerName) {
|
|
403
|
+
keys.push(beatSheetCacheKey({ ...parts, providerName: fallbackName }));
|
|
404
|
+
}
|
|
405
|
+
return keys;
|
|
406
|
+
}
|
|
407
|
+
|
|
350
408
|
/**
|
|
351
409
|
* The clip-window cache key (`clipwindow-<hash>.json`), which answers a
|
|
352
410
|
* different question — WHICH ~Ns of the take to keep (R19 §93) — but asks it
|
|
@@ -1839,6 +1897,20 @@ export function layoutSlotAspects(frame: {
|
|
|
1839
1897
|
});
|
|
1840
1898
|
}
|
|
1841
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
|
+
|
|
1842
1914
|
async function preflight(bin: string, hint: string): Promise<void> {
|
|
1843
1915
|
try {
|
|
1844
1916
|
await run(bin, ["-version"], { allowNonZero: true });
|
|
@@ -2347,6 +2419,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2347
2419
|
// the grounding check — uses the repaired transcript instead, so a
|
|
2348
2420
|
// mishearing can't reach the screen twice in two different spellings.
|
|
2349
2421
|
const providerName = opts.provider ?? defaultProviderName(process.env, binOnPath);
|
|
2422
|
+
// Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
|
|
2423
|
+
// the real beat-sheet call, and auto-detection picks it whenever the CLI is
|
|
2424
|
+
// on PATH. When the editorial call times out, ONE other provider answers
|
|
2425
|
+
// instead of the run dying, announced out loud: the user must know which
|
|
2426
|
+
// model planned their video.
|
|
2427
|
+
//
|
|
2428
|
+
// Function-scope because the beat CACHE needs it too (§150) — the key a
|
|
2429
|
+
// fallback wrote is the second key a re-run has to try, and computing it
|
|
2430
|
+
// twice would let the two drift into disagreeing about where the plan is.
|
|
2431
|
+
const llmFallbackName =
|
|
2432
|
+
providerName === "antigravity"
|
|
2433
|
+
? fallbackProviderName(providerName, process.env, binOnPath)
|
|
2434
|
+
: undefined;
|
|
2350
2435
|
let provider: LlmProvider | null = null;
|
|
2351
2436
|
// --youtube brings its own provider (field gap, 2026-08-16): the user's
|
|
2352
2437
|
// real command was `--youtube --llm antigravity` WITHOUT --produce, and the
|
|
@@ -2363,16 +2448,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2363
2448
|
if (!opts.provider) {
|
|
2364
2449
|
console.log(detectionLine(providerName));
|
|
2365
2450
|
}
|
|
2366
|
-
// Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
|
|
2367
|
-
// the real beat-sheet call — 10-minute --print-timeout expiries while
|
|
2368
|
-
// claude-cli planned the same video — and auto-detection picks agy
|
|
2369
|
-
// whenever the CLI is on PATH. When the editorial call times out, ONE
|
|
2370
|
-
// other provider answers it instead of the run dying, announced out loud:
|
|
2371
|
-
// the user must know which model planned their video.
|
|
2372
|
-
const llmFallbackName =
|
|
2373
|
-
providerName === "antigravity"
|
|
2374
|
-
? fallbackProviderName(providerName, process.env, binOnPath)
|
|
2375
|
-
: undefined;
|
|
2376
2451
|
provider = createTieredProvider(providerName, {
|
|
2377
2452
|
model: opts.llmModel,
|
|
2378
2453
|
fastModel: opts.llmFastModel ?? cfg.fastModel,
|
|
@@ -2730,11 +2805,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2730
2805
|
words: transcript.words.map((w) => w.text),
|
|
2731
2806
|
aspect: landscape ? ("16:9" as const) : ("9:16" as const),
|
|
2732
2807
|
};
|
|
2733
|
-
|
|
2734
|
-
|
|
2808
|
+
// Two keys, tried in order: the provider asked for, then the one this run
|
|
2809
|
+
// would fall back to anyway (§150). Without the second, a workdir whose
|
|
2810
|
+
// plan was written by a fallback can never be read again while the primary
|
|
2811
|
+
// keeps failing — every re-render re-plans, and edits anchored to scenes
|
|
2812
|
+
// the new plan drops go with it.
|
|
2813
|
+
const cacheKeys = beatCacheKeyCandidates(beatKeyParts, providerName, llmFallbackName);
|
|
2814
|
+
const cacheKey = cacheKeys[0]!;
|
|
2815
|
+
const hitKey =
|
|
2816
|
+
cacheKeys.find((k) => existsSync(join(work, `scenes-${k}.json`))) ?? cacheKey;
|
|
2817
|
+
const sceneCache = join(work, `scenes-${hitKey}.json`);
|
|
2735
2818
|
// The cover needs the editorial copy, which is not in the scene list — a
|
|
2736
2819
|
// cached run must still be able to write one.
|
|
2737
|
-
|
|
2820
|
+
// Same key the scenes came from — the hook and cover copy belong to THAT
|
|
2821
|
+
// plan, and pairing a cached scene list with a different sheet's hook
|
|
2822
|
+
// would caption the video with copy for a plan it is not showing.
|
|
2823
|
+
const beatCache = join(work, `beatsheet-${hitKey}.json`);
|
|
2738
2824
|
if (clipFresh) {
|
|
2739
2825
|
// The selection call already planned the scenes (§93d: ONE editorial
|
|
2740
2826
|
// call chooses the window and the beats inside it) — adopt them and
|
|
@@ -2772,7 +2858,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2772
2858
|
);
|
|
2773
2859
|
} else if (existsSync(sceneCache)) {
|
|
2774
2860
|
scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
|
|
2775
|
-
|
|
2861
|
+
// Naming the fallback is the same obligation the live fallback line has
|
|
2862
|
+
// (§143: the user must know which model planned their video). A silent
|
|
2863
|
+
// hit here would let a claude-cli plan read as an antigravity one purely
|
|
2864
|
+
// because it came from disk this time.
|
|
2865
|
+
console.log(
|
|
2866
|
+
hitKey === cacheKey
|
|
2867
|
+
? `▸ scenes cached (${scenes.length})`
|
|
2868
|
+
: `▸ scenes cached (${scenes.length}) — planned by ${llmFallbackName}, which ${providerName} fell back to`,
|
|
2869
|
+
);
|
|
2776
2870
|
if (existsSync(beatCache)) {
|
|
2777
2871
|
// Pre-§118b caches carry no accounting — the report then simply
|
|
2778
2872
|
// omits the graphics section rather than guessing one.
|
|
@@ -2903,18 +2997,29 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2903
2997
|
// single-provider runs stamp exactly as before.
|
|
2904
2998
|
const answered = provider.usage.filter((r) => !r.failed);
|
|
2905
2999
|
const providersSeen = [...new Set(answered.map((r) => r.provider))];
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
3000
|
+
// A run that answered NOTHING must not restamp the artefact (§152). On a
|
|
3001
|
+
// fully cached run `answered` is empty, `last.provider` is the run we just
|
|
3002
|
+
// appended — whose provider is the one we ASKED for — and the stamp
|
|
3003
|
+
// silently rewrote a truthful "antigravity → claude-cli" into
|
|
3004
|
+
// "antigravity", crediting the plan to a provider that never produced it.
|
|
3005
|
+
// The plan did not change this run, so neither does its attribution.
|
|
3006
|
+
const priorStamp = existingProducerStamp(work);
|
|
3007
|
+
if (answered.length === 0 && priorStamp) {
|
|
3008
|
+
producerStamp = priorStamp;
|
|
3009
|
+
} else {
|
|
3010
|
+
producerStamp = {
|
|
3011
|
+
provider:
|
|
3012
|
+
providersSeen.length > 1
|
|
3013
|
+
? providersSeen.join(" → ")
|
|
3014
|
+
: providersSeen[0] ?? last.provider ?? providerName,
|
|
3015
|
+
// `last.models` already excludes failed attempts' models (usage.ts,
|
|
3016
|
+
// same §143 rule) — a stamp that listed the timed-out placeholder read
|
|
3017
|
+
// "planned by claude-cli (antigravity-default)" after a fallback run.
|
|
3018
|
+
models: last.models,
|
|
3019
|
+
cached: last.cached,
|
|
3020
|
+
at: last.at,
|
|
3021
|
+
};
|
|
3022
|
+
}
|
|
2918
3023
|
}
|
|
2919
3024
|
|
|
2920
3025
|
// The overlay and the caption under it must spell the same word (§21).
|
|
@@ -3092,6 +3197,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3092
3197
|
const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
|
|
3093
3198
|
for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
|
|
3094
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
|
+
|
|
3095
3218
|
// ---- Framing plan → props (2026-08-16 incident) --------------------------
|
|
3096
3219
|
// The plan used to be BAKED here: every window cropped, scaled and
|
|
3097
3220
|
// re-encoded into a content-<hash>.mp4 that replaced the source for the
|
|
@@ -3350,7 +3473,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3350
3473
|
console.log(`▸ applied your edits to ${editedCount - orphans.length - hiddenIds.length} scene(s)`);
|
|
3351
3474
|
}
|
|
3352
3475
|
for (const id of orphans) {
|
|
3353
|
-
console.log(
|
|
3476
|
+
console.log(orphanEditLine(id));
|
|
3354
3477
|
}
|
|
3355
3478
|
|
|
3356
3479
|
const graphicCues = sceneCues.filter((c) => c.kind !== "plain");
|
|
@@ -4207,7 +4330,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4207
4330
|
// re-report "the cut removed it" on every later run. It does NOT spend the
|
|
4208
4331
|
// `.bak` — retiring a redundant key is not the cut re-anchoring the backup
|
|
4209
4332
|
// exists to survive.
|
|
4210
|
-
|
|
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) {
|
|
4211
4339
|
await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
|
|
4212
4340
|
console.log(overridesWriteLine(cutResult.changed));
|
|
4213
4341
|
}
|