ossclip 0.1.29 → 0.1.30

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-BBJKC2Z2.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DfTreNoE.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.29",
3
+ "version": "0.1.30",
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.29",
40
- "@ossclip/scenes": "0.1.29",
41
- "@ossclip/renderer": "0.1.29"
39
+ "@ossclip/core": "0.1.30",
40
+ "@ossclip/renderer": "0.1.30",
41
+ "@ossclip/scenes": "0.1.30"
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
- face: z.object({ subject: z.enum(["face", "screen"]).optional() }).optional(),
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/produce.ts CHANGED
@@ -347,6 +347,61 @@ export function beatSheetCacheKey(parts: {
347
347
  .slice(0, 8);
348
348
  }
349
349
 
350
+ /**
351
+ * The beat-sheet cache keys to TRY, in priority order (§150).
352
+ *
353
+ * §143 split the cache: reads use the provider you asked for, writes file
354
+ * under the one that actually answered. That is right for attribution and
355
+ * wrong for re-runs — while agy keeps timing out, a run that asks for
356
+ * antigravity reads a key nothing will ever write. It re-attempts, waits out
357
+ * the whole print-timeout, falls back, and rewrites the key nobody reads.
358
+ * Every re-render therefore re-plans, the plan differs each time, and editor
359
+ * edits anchored to scenes the new plan no longer has are dropped — a
360
+ * re-render silently rewriting an approved cut (2026-08-23: "edit for
361
+ * scene-11 dropped — the plan no longer has that scene").
362
+ *
363
+ * So the read tries a second key, and exactly one: the provider THIS run
364
+ * would fall back to anyway. Serving that sheet is not a substitution — it is
365
+ * what this run would produce, without paying the timeout to rediscover it.
366
+ * Anything looser (any provider's sheet, newest file wins) would hand a
367
+ * claude-cli plan to someone who asked for gemini and got gemini.
368
+ */
369
+ /**
370
+ * The producer stamp already on disk, or undefined (§152).
371
+ *
372
+ * Read so a run that answered NOTHING can keep it. `production.json` is
373
+ * rewritten on every produce, cached or not, and the stamp is rebuilt from
374
+ * this run's usage records — which on a cached run are empty, so it fell
375
+ * through to the provider we ASKED for and quietly overwrote a truthful
376
+ * "antigravity → claude-cli" with "antigravity". Attribution belongs to the
377
+ * run that produced the plan, and a cached run produced nothing.
378
+ *
379
+ * Tolerant on purpose: a missing, unreadable or stamp-less file all mean "no
380
+ * prior attribution", which is the same answer a first run gives.
381
+ */
382
+ export function existingProducerStamp(work: string): Production["producer"] | undefined {
383
+ try {
384
+ const raw = JSON.parse(readFileSync(join(work, "production.json"), "utf8")) as {
385
+ producer?: Production["producer"];
386
+ };
387
+ return raw.producer;
388
+ } catch {
389
+ return undefined;
390
+ }
391
+ }
392
+
393
+ export function beatCacheKeyCandidates(
394
+ parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
395
+ providerName: string,
396
+ fallbackName: string | undefined,
397
+ ): string[] {
398
+ const keys = [beatSheetCacheKey({ ...parts, providerName })];
399
+ if (fallbackName && fallbackName !== providerName) {
400
+ keys.push(beatSheetCacheKey({ ...parts, providerName: fallbackName }));
401
+ }
402
+ return keys;
403
+ }
404
+
350
405
  /**
351
406
  * The clip-window cache key (`clipwindow-<hash>.json`), which answers a
352
407
  * different question — WHICH ~Ns of the take to keep (R19 §93) — but asks it
@@ -2347,6 +2402,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2347
2402
  // the grounding check — uses the repaired transcript instead, so a
2348
2403
  // mishearing can't reach the screen twice in two different spellings.
2349
2404
  const providerName = opts.provider ?? defaultProviderName(process.env, binOnPath);
2405
+ // Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
2406
+ // the real beat-sheet call, and auto-detection picks it whenever the CLI is
2407
+ // on PATH. When the editorial call times out, ONE other provider answers
2408
+ // instead of the run dying, announced out loud: the user must know which
2409
+ // model planned their video.
2410
+ //
2411
+ // Function-scope because the beat CACHE needs it too (§150) — the key a
2412
+ // fallback wrote is the second key a re-run has to try, and computing it
2413
+ // twice would let the two drift into disagreeing about where the plan is.
2414
+ const llmFallbackName =
2415
+ providerName === "antigravity"
2416
+ ? fallbackProviderName(providerName, process.env, binOnPath)
2417
+ : undefined;
2350
2418
  let provider: LlmProvider | null = null;
2351
2419
  // --youtube brings its own provider (field gap, 2026-08-16): the user's
2352
2420
  // real command was `--youtube --llm antigravity` WITHOUT --produce, and the
@@ -2363,16 +2431,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2363
2431
  if (!opts.provider) {
2364
2432
  console.log(detectionLine(providerName));
2365
2433
  }
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
2434
  provider = createTieredProvider(providerName, {
2377
2435
  model: opts.llmModel,
2378
2436
  fastModel: opts.llmFastModel ?? cfg.fastModel,
@@ -2730,11 +2788,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2730
2788
  words: transcript.words.map((w) => w.text),
2731
2789
  aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2732
2790
  };
2733
- const cacheKey = beatSheetCacheKey({ ...beatKeyParts, providerName });
2734
- const sceneCache = join(work, `scenes-${cacheKey}.json`);
2791
+ // Two keys, tried in order: the provider asked for, then the one this run
2792
+ // would fall back to anyway (§150). Without the second, a workdir whose
2793
+ // plan was written by a fallback can never be read again while the primary
2794
+ // keeps failing — every re-render re-plans, and edits anchored to scenes
2795
+ // the new plan drops go with it.
2796
+ const cacheKeys = beatCacheKeyCandidates(beatKeyParts, providerName, llmFallbackName);
2797
+ const cacheKey = cacheKeys[0]!;
2798
+ const hitKey =
2799
+ cacheKeys.find((k) => existsSync(join(work, `scenes-${k}.json`))) ?? cacheKey;
2800
+ const sceneCache = join(work, `scenes-${hitKey}.json`);
2735
2801
  // The cover needs the editorial copy, which is not in the scene list — a
2736
2802
  // cached run must still be able to write one.
2737
- const beatCache = join(work, `beatsheet-${cacheKey}.json`);
2803
+ // Same key the scenes came from — the hook and cover copy belong to THAT
2804
+ // plan, and pairing a cached scene list with a different sheet's hook
2805
+ // would caption the video with copy for a plan it is not showing.
2806
+ const beatCache = join(work, `beatsheet-${hitKey}.json`);
2738
2807
  if (clipFresh) {
2739
2808
  // The selection call already planned the scenes (§93d: ONE editorial
2740
2809
  // call chooses the window and the beats inside it) — adopt them and
@@ -2772,7 +2841,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2772
2841
  );
2773
2842
  } else if (existsSync(sceneCache)) {
2774
2843
  scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
2775
- console.log(`▸ scenes cached (${scenes.length})`);
2844
+ // Naming the fallback is the same obligation the live fallback line has
2845
+ // (§143: the user must know which model planned their video). A silent
2846
+ // hit here would let a claude-cli plan read as an antigravity one purely
2847
+ // because it came from disk this time.
2848
+ console.log(
2849
+ hitKey === cacheKey
2850
+ ? `▸ scenes cached (${scenes.length})`
2851
+ : `▸ scenes cached (${scenes.length}) — planned by ${llmFallbackName}, which ${providerName} fell back to`,
2852
+ );
2776
2853
  if (existsSync(beatCache)) {
2777
2854
  // Pre-§118b caches carry no accounting — the report then simply
2778
2855
  // omits the graphics section rather than guessing one.
@@ -2903,18 +2980,29 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2903
2980
  // single-provider runs stamp exactly as before.
2904
2981
  const answered = provider.usage.filter((r) => !r.failed);
2905
2982
  const providersSeen = [...new Set(answered.map((r) => r.provider))];
2906
- producerStamp = {
2907
- provider:
2908
- providersSeen.length > 1
2909
- ? providersSeen.join(" → ")
2910
- : providersSeen[0] ?? last.provider ?? providerName,
2911
- // `last.models` already excludes failed attempts' models (usage.ts,
2912
- // same §143 rule) — a stamp that listed the timed-out placeholder read
2913
- // "planned by claude-cli (antigravity-default)" after a fallback run.
2914
- models: last.models,
2915
- cached: last.cached,
2916
- at: last.at,
2917
- };
2983
+ // A run that answered NOTHING must not restamp the artefact (§152). On a
2984
+ // fully cached run `answered` is empty, `last.provider` is the run we just
2985
+ // appended — whose provider is the one we ASKED for — and the stamp
2986
+ // silently rewrote a truthful "antigravity → claude-cli" into
2987
+ // "antigravity", crediting the plan to a provider that never produced it.
2988
+ // The plan did not change this run, so neither does its attribution.
2989
+ const priorStamp = existingProducerStamp(work);
2990
+ if (answered.length === 0 && priorStamp) {
2991
+ producerStamp = priorStamp;
2992
+ } else {
2993
+ producerStamp = {
2994
+ provider:
2995
+ providersSeen.length > 1
2996
+ ? providersSeen.join(" → ")
2997
+ : providersSeen[0] ?? last.provider ?? providerName,
2998
+ // `last.models` already excludes failed attempts' models (usage.ts,
2999
+ // same §143 rule) — a stamp that listed the timed-out placeholder read
3000
+ // "planned by claude-cli (antigravity-default)" after a fallback run.
3001
+ models: last.models,
3002
+ cached: last.cached,
3003
+ at: last.at,
3004
+ };
3005
+ }
2918
3006
  }
2919
3007
 
2920
3008
  // The overlay and the caption under it must spell the same word (§21).