@ossclip/core 0.1.26 → 0.1.28

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.
@@ -0,0 +1,91 @@
1
+ import type { z } from "zod/v4";
2
+ import { AgyError } from "./antigravity";
3
+ import type { CallTier, LlmProvider } from "./provider";
4
+ import type { LlmUsage } from "./usage";
5
+
6
+ /**
7
+ * What a fallback announcement needs to say: who failed, who is answering
8
+ * instead, on which call, and (first line of) the failure's own sentence.
9
+ */
10
+ export interface FallbackInfo {
11
+ from: string;
12
+ to: string;
13
+ schemaName: string;
14
+ detail: string;
15
+ }
16
+
17
+ /**
18
+ * Falls back to a second provider when — and only when — the primary TIMES
19
+ * OUT (2026-08-22, FINDINGS §143). agy is healthy at small scale and then
20
+ * hangs persistently on the real beat-sheet call: measured 10-minute
21
+ * `--print-timeout` expiries on an 11-minute take while claude-cli planned
22
+ * the same video without complaint — and auto-detection picks agy whenever
23
+ * the CLI is on PATH, so without this every Antigravity user's first real
24
+ * produce dies after the wait.
25
+ *
26
+ * Only the `timeout` class falls back: auth and bad-model failures are
27
+ * deterministic and must keep failing fast with their own guidance —
28
+ * answering them from another provider would paper over a config error the
29
+ * user needs to see. Exactly ONE fallback attempt, no loop: the fallback
30
+ * provider runs its own internal retries.
31
+ *
32
+ * `name` stays the primary's — the detection line already announced it — and
33
+ * the truth of who actually answered lives in `usage` (each record keeps the
34
+ * provider that really made the call) plus the caller's out-loud `onFallback`
35
+ * line. Silent substitution would be worse than the hang.
36
+ */
37
+ export class FallbackProvider implements LlmProvider {
38
+ readonly name: string;
39
+ private readonly records: LlmUsage[] = [];
40
+
41
+ constructor(
42
+ private readonly primary: LlmProvider,
43
+ private readonly fallback: LlmProvider,
44
+ private readonly onFallback?: (info: FallbackInfo) => void,
45
+ ) {
46
+ this.name = primary.name;
47
+ }
48
+
49
+ /** Merged in call order — the sub-providers each keep their own log. */
50
+ get usage(): readonly LlmUsage[] {
51
+ return this.records;
52
+ }
53
+
54
+ async complete<T>(req: {
55
+ system: string;
56
+ user: string;
57
+ schema: z.ZodType<T>;
58
+ schemaName: string;
59
+ maxTokens?: number;
60
+ tier?: CallTier;
61
+ }): Promise<T> {
62
+ const beforePrimary = this.primary.usage.length;
63
+ const beforeFallback = this.fallback.usage.length;
64
+ try {
65
+ try {
66
+ return await this.primary.complete(req);
67
+ } catch (err) {
68
+ // Branch on the class as DATA — AgyError carries it precisely so a
69
+ // fallback decorator never re-parses human-facing prose
70
+ // (antigravity.ts). Everything that is not an agy timeout rethrows.
71
+ if (!(err instanceof AgyError) || err.failureClass !== "timeout") throw err;
72
+ this.onFallback?.({
73
+ from: this.primary.name,
74
+ to: this.fallback.name,
75
+ schemaName: req.schemaName,
76
+ // First line only: agyFailureMessage is multi-line guidance, and
77
+ // the announcement needs the sentence, not the manual.
78
+ detail: err.message.split("\n")[0]!.trim(),
79
+ });
80
+ return await this.fallback.complete(req);
81
+ }
82
+ } finally {
83
+ // Drain whatever BOTH sub-providers logged, including for calls that
84
+ // threw — a failed attempt still spent the tokens (tiered.ts, §37).
85
+ // Each record keeps the provider that really made the call, which is
86
+ // the attribution the cache keys and the usage report depend on.
87
+ this.records.push(...this.primary.usage.slice(beforePrimary));
88
+ this.records.push(...this.fallback.usage.slice(beforeFallback));
89
+ }
90
+ }
91
+ }
@@ -2,11 +2,12 @@ import type { Transcript } from "../schema";
2
2
  import type { Scene, SceneComponentId } from "../scene-schema";
3
3
  import type { LlmProvider, ProviderName } from "./provider";
4
4
  import { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
5
- import { AntigravityProvider } from "./antigravity";
5
+ import { AntigravityProvider, type LlmEffort } from "./antigravity";
6
6
  import { ClaudeCliProvider } from "./claude-cli";
7
7
  import { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
8
8
  import { MockProvider } from "./mock";
9
9
  import { TieredProvider } from "./tiered";
10
+ import { FallbackProvider, type FallbackInfo } from "./fallback";
10
11
  import {
11
12
  generateBeatSheet,
12
13
  normalizeBeatSheet,
@@ -29,13 +30,22 @@ export * from "./youtube";
29
30
  export * from "./scene-props";
30
31
  export * from "./repair";
31
32
  export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
32
- export { AntigravityProvider } from "./antigravity";
33
+ export { AntigravityProvider, type LlmEffort } from "./antigravity";
33
34
  export { ClaudeCliProvider } from "./claude-cli";
34
35
  export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
35
36
  export { MockProvider } from "./mock";
36
37
  export { TieredProvider } from "./tiered";
38
+ export { FallbackProvider, type FallbackInfo } from "./fallback";
37
39
 
38
- export function createProvider(name: ProviderName, model?: string): LlmProvider {
40
+ export function createProvider(
41
+ name: ProviderName,
42
+ model?: string,
43
+ // A trailing bag, not a third positional per knob: every existing
44
+ // (name, model) call site keeps compiling. Only antigravity reads `effort`
45
+ // today (§143) — the other providers have no such flag, and inventing a
46
+ // mapping for them would be a coercion of intent.
47
+ opts: { effort?: LlmEffort } = {},
48
+ ): LlmProvider {
39
49
  switch (name) {
40
50
  case "claude":
41
51
  return new AnthropicProvider(model ?? DEFAULT_CLAUDE_MODEL);
@@ -48,7 +58,7 @@ export function createProvider(name: ProviderName, model?: string): LlmProvider
48
58
  // Rides agy's cached subscription sign-in. No default model on purpose:
49
59
  // the editorial tier runs whatever the user configured agy itself to
50
60
  // use (FINDINGS §132, antigravity provider).
51
- return new AntigravityProvider(model);
61
+ return new AntigravityProvider(model, undefined, { effort: opts.effort });
52
62
  case "mock":
53
63
  return new MockProvider();
54
64
  }
@@ -80,6 +90,26 @@ export interface TieringOptions {
80
90
  * tiering and sends everything to the editorial model.
81
91
  */
82
92
  fastModel?: string;
93
+ /**
94
+ * Provider to fall back to when the editorial call TIMES OUT (2026-08-22,
95
+ * FINDINGS §143). Honored only when the primary is antigravity — the one
96
+ * provider measured to hang on the real beat-sheet call. See
97
+ * `fallbackProviderName` for who is eligible.
98
+ */
99
+ fallback?: ProviderName;
100
+ /**
101
+ * Fired once, before the fallback call — the caller announces it out loud.
102
+ * Silent substitution is the failure mode the fallback exists to avoid.
103
+ */
104
+ onFallback?: (info: FallbackInfo) => void;
105
+ /**
106
+ * `agy --effort` for the EDITORIAL antigravity call only (§143: exposed
107
+ * after the hang incident — the knob existed and we passed nothing). The
108
+ * mechanical tier keeps agy's default (its small calls never hung), and the
109
+ * §143 fallback never sees it — it is a different provider, and the
110
+ * primary's effort level means nothing to it.
111
+ */
112
+ effort?: LlmEffort;
83
113
  }
84
114
 
85
115
  /**
@@ -91,7 +121,16 @@ export function createTieredProvider(
91
121
  name: ProviderName,
92
122
  opts: TieringOptions = {},
93
123
  ): LlmProvider {
94
- const editorial = createProvider(name, opts.model);
124
+ // The §143 effort knob rides the editorial call only — see TieringOptions.
125
+ let editorial = createProvider(name, opts.model, { effort: opts.effort });
126
+ // Timeout fallback (2026-08-22, FINDINGS §143): only the editorial tier
127
+ // wraps — the beat-sheet call is the one measured to outrun agy's print
128
+ // timeout; mechanical calls are small enough to finish. The fallback gets
129
+ // NO model override: the primary's model name means nothing to a different
130
+ // provider, so the fallback runs its own default.
131
+ if (name === "antigravity" && opts.fallback) {
132
+ editorial = new FallbackProvider(editorial, createProvider(opts.fallback), opts.onFallback);
133
+ }
95
134
  const fast = opts.fastModel === "same" ? undefined : opts.fastModel ?? DEFAULT_FAST_MODEL[name];
96
135
  if (!fast || fast === opts.model) return editorial;
97
136
  return new TieredProvider(editorial, createProvider(name, fast));
@@ -132,6 +171,27 @@ export function defaultProviderName(
132
171
  return "claude-cli";
133
172
  }
134
173
 
174
+ /**
175
+ * Where a timed-out antigravity run falls back to (2026-08-22, FINDINGS
176
+ * §143). Only antigravity gets one: it is the sole provider measured to hang
177
+ * persistently on the real beat-sheet call, and handing every provider a
178
+ * second choice would turn one measured incident into a general substitution
179
+ * policy. The order is `defaultProviderName`'s with agy removed — a logged-in
180
+ * claude CLI beats the gemini key for the same §132 subscription-first
181
+ * reasons — and the `hasBin` default of `() => false` keeps this pure the
182
+ * same way: callers that can see a filesystem inject a real checker.
183
+ */
184
+ export function fallbackProviderName(
185
+ primary: ProviderName,
186
+ env: NodeJS.ProcessEnv = process.env,
187
+ hasBin: (bin: string) => boolean = () => false,
188
+ ): ProviderName | undefined {
189
+ if (primary !== "antigravity") return undefined;
190
+ if (hasBin(env.OSSCLIP_CLAUDE_BIN ?? "claude")) return "claude-cli";
191
+ if (env.GEMINI_API_KEY) return "gemini";
192
+ return undefined;
193
+ }
194
+
135
195
  export interface ProduceScenesResult {
136
196
  beatSheet: BeatSheet;
137
197
  beatIssues: BeatsValidationIssue[];
@@ -45,6 +45,14 @@ export interface LlmUsage {
45
45
  billed: boolean;
46
46
  /** Wall-clock milliseconds, so a slow call is visible beside a costly one. */
47
47
  ms?: number;
48
+ /**
49
+ * True when this ATTEMPT did not produce the artifact (a timed-out or
50
+ * errored call, §143). The cost is real and stays in every usage report —
51
+ * the flag exists so ATTRIBUTION can skip it: a production.json stamp that
52
+ * listed the failed attempt's placeholder model read "planned by
53
+ * claude-cli (antigravity-default)" after a fallback run (2026-08-22).
54
+ */
55
+ failed?: boolean;
48
56
  }
49
57
 
50
58
  export interface ModelPrice {
@@ -288,6 +296,20 @@ export function formatUsageReport(
288
296
  if (records.length === 0) return "";
289
297
  const t = summarizeUsage(records, pricing);
290
298
  const first = records[0]!;
299
+ // After a §143 timeout fallback (2026-08-22) the records genuinely span two
300
+ // providers, and a header naming only records[0]'s would credit the whole
301
+ // run to the one that failed the editorial call. First-seen order joined
302
+ // " → " says who handed off to whom; the model suffix drops in that case
303
+ // because one model name would be attributed to calls another model made.
304
+ // Single-provider runs render byte-identically to before.
305
+ const providers: string[] = [];
306
+ for (const r of records) {
307
+ if (!providers.includes(r.provider)) providers.push(r.provider);
308
+ }
309
+ const who =
310
+ providers.length > 1
311
+ ? providers.join(" → ")
312
+ : `${first.provider}${first.model ? ` · ${first.model}` : ""}`;
291
313
  // A column of $0.0000 says nothing; an offline run's cost column is a dash.
292
314
  const free = t.allUnbilled && t.equivalentUsd === 0;
293
315
  const money = (v: number | null): string => (free ? "—" : v === null ? "?" : usd(v));
@@ -326,7 +348,7 @@ export function formatUsageReport(
326
348
  notes.push(" Prices are the built-in per-family assumption; override in ~/.ossclip/config.json.");
327
349
  }
328
350
  return (
329
- `\nllm usage (${first.provider}${first.model ? ` · ${first.model}` : ""}` +
351
+ `\nllm usage (${who}` +
330
352
  `${t.ms > 0 ? `, ${secs(t.ms)}` : ""}):\n` +
331
353
  rows.join("\n") +
332
354
  "\n" +
@@ -387,6 +409,9 @@ function modelsOfLog(log: Pick<UsageLog, "runs" | "records">): string[] {
387
409
  }
388
410
  const seen: string[] = [];
389
411
  for (const r of log.records) {
412
+ // Failed attempts never contribute a model (§143) — same rule as the
413
+ // per-run list below.
414
+ if (r.failed) continue;
390
415
  if (r.model && !seen.includes(r.model)) seen.push(r.model);
391
416
  }
392
417
  return seen;
@@ -404,6 +429,11 @@ export function appendUsageRun(
404
429
  const cached = records.length === 0;
405
430
  const models: string[] = [];
406
431
  for (const r of records) {
432
+ // A run's model list answers "who made this", not "who was asked" — a
433
+ // failed attempt's model (the timed-out agy placeholder, §143) stays in
434
+ // the records and every cost report, but never in the attribution list a
435
+ // cached run inherits or the production stamp copies.
436
+ if (r.failed) continue;
407
437
  if (r.model && !models.includes(r.model)) models.push(r.model);
408
438
  }
409
439
  // A cached run inherits the models it is REUSING the output of, exactly as
package/src/recut.ts CHANGED
@@ -25,8 +25,13 @@ export interface RecutRemap {
25
25
  * caption/overlay boundaries, so a value pushed onto a cut lands exactly
26
26
  * where the timeline's own dead-region rendering shows the cut's edge.
27
27
  * `label` is only for the report string — it carries no behavior.
28
+ *
29
+ * Exported since cut review step 4: `retimeForPreview` (retime-preview.ts)
30
+ * moves every output-timed render prop through THIS function so the editor's
31
+ * live post-veto preview and produce's own re-anchoring can never disagree
32
+ * about what "the same moment on the new clock" means.
28
33
  */
29
- function remapPoint(
34
+ export function remapPoint(
30
35
  label: string,
31
36
  t: number,
32
37
  oldMap: TimeMap,
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The editor's live post-veto preview (cut review step 4): when the user
3
+ * declines a removal produce proposed, the preview's own timeline is re-cut
4
+ * so the player actually PLAYS the revived material, immediately, instead of
5
+ * marking a seam that only the next render honours.
6
+ *
7
+ * Cleanup vetoes ONLY. User `cuts[]` stay marked-not-applied, exactly the
8
+ * step-3 posture, for two reasons that are not the retired "no client-side
9
+ * TimeMap" one:
10
+ * - produce alone resolves a fresh cut's `src` (the `cuts[].src` schema
11
+ * contract, overrides.ts) — the editor must never apply a cut whose
12
+ * source range only produce can resolve;
13
+ * - a veto RESTORES content the mezzanine already has, so the editor can
14
+ * honestly play it; a cut REMOVES content, and the struck band already
15
+ * communicates that honestly.
16
+ *
17
+ * Pure and browser-safe by construction (the cover-headline.ts split): this
18
+ * module's whole import graph — cutlist, recut, timemap, and types — has
19
+ * zero node built-ins, so it rides `@ossclip/core/browser` into the editor
20
+ * bundle, and every function here is testable without a TTY or a filesystem.
21
+ */
22
+
23
+ import type { CaptionLine } from "./captions";
24
+ import { applyCleanupChoices, type CleanupChoices } from "./cutlist";
25
+ import { remapPoint, subtractRangesFromCutlist, type UserCut } from "./recut";
26
+ import type { SceneCue } from "./scene-schema";
27
+ import type { Segment } from "./schema";
28
+ import { mapFromKeptSpans, mapsClose, TimeMap, type KeptSpan } from "./timemap";
29
+ import type { ZoomSegment } from "./zoom";
30
+
31
+ /** `applyUserCuts`'s EPS — a JSON round-trip plus TimeMap arithmetic is
32
+ * noise, a real veto is never under a millisecond. */
33
+ const EPS = 1e-6;
34
+
35
+ /** Both clocks the retime needs: the one the current render-props are timed
36
+ * against, and the one the user's cleanup choices produce. */
37
+ export interface LivePreviewClocks {
38
+ oldMap: TimeMap;
39
+ newMap: TimeMap;
40
+ }
41
+
42
+ /**
43
+ * Whether the current cleanup choices change the timeline at all — and the
44
+ * two clocks to retime through when they do. `null` is the identity signal:
45
+ * the caller must hand the props through UNTOUCHED (the regression anchor —
46
+ * a doc with no live veto must leave the preview byte-identical to today's).
47
+ *
48
+ * The new clock is produce's own sequence, same functions, same order:
49
+ * `applyCleanupChoices(proposal, choices)` then user cuts subtract from the
50
+ * result (`subtractRangesFromCutlist`), so a user cut drawn over a vetoed
51
+ * pause still cuts here exactly as it does in produce. Only cuts whose `src`
52
+ * is already resolved subtract — a fresh cut's source range is produce's
53
+ * alone to resolve (`resolveCutSourceRanges` needs the prior render-props
54
+ * frame), and that cut is still marked-not-applied on the timeline anyway.
55
+ * Skipping the subtraction entirely would be worse than incomplete: every
56
+ * ALREADY-APPLIED cut (src resolved by a past produce, absent from
57
+ * `oldSpans`) would silently come back the moment any veto went live.
58
+ *
59
+ * `null` on any degenerate input — no proposal, no old spans, choices with
60
+ * no actual veto — and on a proposal `TimeMap`'s constructor rejects (a
61
+ * hand-mangled production.json): the preview degrades to step 3's honest
62
+ * marks-rather-than-applies, never a crash, the same lenient posture as
63
+ * GET /api/cleanup itself.
64
+ */
65
+ export function livePreviewMap(
66
+ proposal: readonly Segment[],
67
+ choices: CleanupChoices | undefined,
68
+ cuts: readonly UserCut[],
69
+ oldSpans: readonly KeptSpan[],
70
+ ): LivePreviewClocks | null {
71
+ // "Non-empty" means a veto actually present — a `reasons` map of tolerated
72
+ // `true` entries restates the default (the schema comment) and must take
73
+ // the cheap exact exit, not a float comparison of two equal maps.
74
+ const hasVeto =
75
+ Object.values(choices?.reasons ?? {}).some((v) => v === false) ||
76
+ (choices?.kept?.length ?? 0) > 0;
77
+ if (!hasVeto) return null;
78
+ if (proposal.length === 0 || oldSpans.length === 0) return null;
79
+ try {
80
+ const rekept = applyCleanupChoices(proposal, choices);
81
+ const ranges = cuts.flatMap((c) =>
82
+ c.src && c.src.endSec > c.src.startSec
83
+ ? [{ start: c.src.startSec, end: c.src.endSec }]
84
+ : [],
85
+ );
86
+ const newMap = new TimeMap(subtractRangesFromCutlist(rekept, ranges));
87
+ const oldMap = mapFromKeptSpans(oldSpans);
88
+ // Choices that change nothing (a veto already baked into the last
89
+ // produce's spans, a kept range overlapping no removal) are identity.
90
+ if (mapsClose(oldMap, newMap, EPS)) return null;
91
+ return { oldMap, newMap };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The two clocks as POINT mappers, one per direction. `retimeForPreview`
99
+ * below moves the player's PROPS onto the new clock in one batch, but the
100
+ * editor also has surfaces that read or write a SINGLE instant at a gesture
101
+ * — the transcript's click-to-seek, the timeline's ghost bands, the cover
102
+ * panel's playhead — and each of those needs the same old-output → source →
103
+ * new-output walk as a plain function it can be handed without knowing the
104
+ * recut machinery behind it.
105
+ */
106
+ export interface PreviewClockMappers {
107
+ /** OLD-clock output seconds (the last render's own timeline — what the
108
+ * render props, the ghost cues and the pre-retime caption lines are timed
109
+ * in) → the clock the player is actually on. Exact for every live veto:
110
+ * vetoes only ever ADD time back, so every old moment survives on the new
111
+ * clock (`retimeForPreview`'s direction argument); the clamp behind it
112
+ * only fires for the retracted-veto shape the retime already reports. */
113
+ toLive: (sec: number) => number;
114
+ /** The reverse: the player's clock → the last render's own output seconds.
115
+ * A live moment inside REVIVED material has no old-clock preimage at all —
116
+ * the rendered mp4 never contained that frame — so it clamps to the
117
+ * nearest kept edge (`toOutputClamped`'s documented role), the closest
118
+ * moment the old clock can honestly name. */
119
+ fromLive: (sec: number) => number;
120
+ /** Whether a live instant EXISTS on the old clock at all — false exactly
121
+ * when `fromLive` would have to clamp: the moment sits inside REVIVED
122
+ * material (a vetoed removal the last render cut away). The WRITE-direction
123
+ * guard (the follow-up to `fromLive`'s read direction): the doc's own time
124
+ * slots speak the OLD clock (`splits[].at` per SplitSchema, a fresh cut's
125
+ * `startSec`/`endSec` per the `cuts` schema comment — overrides.ts), and a
126
+ * writer facing a moment this answers false for must refuse OUT LOUD
127
+ * rather than let the clamp silently relocate the user's gesture to the
128
+ * seam — the recut.ts "reported, never silently dropped" rule, applied
129
+ * before the write instead of after. Asked as its own question, not an ad
130
+ * hoc float comparison of `toLive(fromLive(sec))` against `sec` at some
131
+ * caller-invented tolerance. An instant exactly AT a seam counts as HAVING
132
+ * a preimage: `toOutput`'s containment is inclusive of both span edges
133
+ * (timemap.ts), so the seam moment is one the last render still contained.
134
+ * Always true for the identity pair — no veto, nothing revived. */
135
+ hasOldClockPreimage: (sec: number) => boolean;
136
+ }
137
+
138
+ /**
139
+ * The mappers for the current live re-cut — or the IDENTITY pair when there
140
+ * is none (`clocks === null`, `livePreviewMap`'s own identity signal). The
141
+ * identity is literally `(sec) => sec`, so a consumer's no-veto path computes
142
+ * bit-identical values to what it computed before the mapping existed — the
143
+ * same regression anchor the `live` memo's null branch holds to.
144
+ */
145
+ export function previewClockMappers(clocks: LivePreviewClocks | null): PreviewClockMappers {
146
+ if (clocks === null) {
147
+ const identity = (sec: number): number => sec;
148
+ return { toLive: identity, fromLive: identity, hasOldClockPreimage: () => true };
149
+ }
150
+ const { oldMap, newMap } = clocks;
151
+ return {
152
+ toLive: (sec) => {
153
+ const src = oldMap.toSource(sec);
154
+ return newMap.toOutput(src) ?? newMap.toOutputClamped(src);
155
+ },
156
+ fromLive: (sec) => {
157
+ const src = newMap.toSource(sec);
158
+ return oldMap.toOutput(src) ?? oldMap.toOutputClamped(src);
159
+ },
160
+ // `fromLive`'s exact half, asked as a question: `toOutput` is null
161
+ // precisely when the source instant fell in a region the old map removed
162
+ // — i.e. the live moment is inside revived material (its own doc comment
163
+ // above pins the inclusive-seam semantics).
164
+ hasOldClockPreimage: (sec) => oldMap.toOutput(newMap.toSource(sec)) !== null,
165
+ };
166
+ }
167
+
168
+ /** `cutRangeToOldClock`'s verdict on a live-clock window headed for a doc
169
+ * `cuts[]` slot. `exact`/`shrunk` carry OLD-clock seconds ready to store;
170
+ * `shrunk` also carries a report (the `remapPoint` posture — a moved value
171
+ * says so) for the caller's feedback channel; `degenerate` means the window
172
+ * has NO old-clock extent at all and the write must be refused out loud. */
173
+ export type OldClockCutRange =
174
+ | { kind: "exact"; startSec: number; endSec: number }
175
+ | { kind: "shrunk"; startSec: number; endSec: number; report: string }
176
+ | { kind: "degenerate" };
177
+
178
+ /**
179
+ * Convert a cut gesture's LIVE-clock window into the OLD-clock window the
180
+ * doc's `cuts[]` slots speak (the schema comment on `OverrideDocSchema.cuts`:
181
+ * a fresh cut's `startSec`/`endSec` are drawn against the LAST render-props'
182
+ * frame — produce resolves `src` by mapping them through the PRIOR TimeMap,
183
+ * so a new-clock number stored there lands the cut the revived seconds off).
184
+ *
185
+ * Endpoints inside revived material clamp to the nearest kept edge
186
+ * (`fromLive`'s doc): when only ONE edge clamps the range SHRINKS there and
187
+ * the cut proceeds on what the old clock can express — the source range
188
+ * produce resolves from the shrunk window still spans the revived material
189
+ * BETWEEN the endpoints (a contiguous source interval), so only the revived
190
+ * sliver past the clamped edge is lost, and the report says so. When the
191
+ * whole window collapses to one point — both endpoints inside one revived
192
+ * region, or the window exactly covering it seam to seam (each seam HAS a
193
+ * preimage, but the same one twice) — there is nothing left to cut and the
194
+ * verdict is `degenerate`: refuse, the ⌘B-split posture, never a silent
195
+ * zero-length entry. Checked on the mapped WIDTH first, before the preimage
196
+ * question, for exactly that seam-to-seam case. The module `EPS`, not 0: the
197
+ * mapped ends ride TimeMap arithmetic, and a real cut is never under a
198
+ * microsecond.
199
+ *
200
+ * Identity mappers (no live veto) always answer `exact` with the input
201
+ * values untouched — the no-veto regression anchor.
202
+ */
203
+ export function cutRangeToOldClock(
204
+ mappers: Pick<PreviewClockMappers, "fromLive" | "hasOldClockPreimage">,
205
+ startSec: number,
206
+ endSec: number,
207
+ ): OldClockCutRange {
208
+ const mappedStart = mappers.fromLive(startSec);
209
+ const mappedEnd = mappers.fromLive(endSec);
210
+ if (mappedEnd - mappedStart < EPS) return { kind: "degenerate" };
211
+ if (mappers.hasOldClockPreimage(startSec) && mappers.hasOldClockPreimage(endSec)) {
212
+ return { kind: "exact", startSec: mappedStart, endSec: mappedEnd };
213
+ }
214
+ return {
215
+ kind: "shrunk",
216
+ startSec: mappedStart,
217
+ endSec: mappedEnd,
218
+ report:
219
+ `cut ${startSec.toFixed(3)}s–${endSec.toFixed(3)}s trimmed to the last render's ` +
220
+ `${mappedStart.toFixed(3)}s–${mappedEnd.toFixed(3)}s — the revived material at its ` +
221
+ `edge isn't in the last render yet`,
222
+ };
223
+ }
224
+
225
+ /** The output-timed subset of the render props the retime reads. Structural
226
+ * on purpose — the renderer's `ProductionCompProps` satisfies it without
227
+ * core importing the renderer package. */
228
+ export interface RetimeablePreviewProps {
229
+ outputDurationSec: number;
230
+ captionLines: readonly CaptionLine[];
231
+ sceneCues: readonly SceneCue[];
232
+ zoomPlan?: readonly ZoomSegment[];
233
+ ctaWindow?: { startSec: number; endSec: number };
234
+ sourceTextRegions?: readonly { y: number; h: number; startSec: number; endSec: number }[];
235
+ }
236
+
237
+ /** Exactly the fields `retimeForPreview` re-timed — the caller spreads them
238
+ * over the full props (`{ ...props, ...fields }`), so fields this function
239
+ * never touches (theme, face, framingTimeline — all source-timed or
240
+ * timeless) cannot be accidentally rewritten here. */
241
+ export interface RetimedPreviewFields {
242
+ spans: KeptSpan[];
243
+ outputDurationSec: number;
244
+ captionLines: CaptionLine[];
245
+ sceneCues: SceneCue[];
246
+ zoomPlan?: ZoomSegment[];
247
+ ctaWindow?: { startSec: number; endSec: number };
248
+ sourceTextRegions?: { y: number; h: number; startSec: number; endSec: number }[];
249
+ punch: { scale: number; allowed: boolean[] };
250
+ }
251
+
252
+ export interface RetimedPreview {
253
+ fields: RetimedPreviewFields;
254
+ reports: string[];
255
+ }
256
+
257
+ /**
258
+ * Re-time every output-timed render prop from `oldMap`'s clock onto
259
+ * `newMap`'s: old-output → source → new-output, `remapPoint`'s exact
260
+ * algorithm — the same one produce re-anchors splits and pins with.
261
+ *
262
+ * Vetoes only ever ADD time back (a removal becomes a keep), so every moment
263
+ * the old clock could express survives on the new one and maps exactly. The
264
+ * clamped fallback still stands behind each point (`toOutputClamped`'s
265
+ * documented role) for the one direction that can remove time: the old spans
266
+ * carrying a veto the doc no longer holds — a moment inside it snaps to the
267
+ * nearest kept edge and is reported, never silently dropped.
268
+ *
269
+ * Word `srcStart` is already SOURCE time (§137's recut-immune key) and is
270
+ * carried untouched. `punch` comes back provably inert — `{scale: 1,
271
+ * allowed: []}`: `punchScalesFor` (punch-plan.ts) renders an allowed span's
272
+ * punched turn at `scale`, and scale 1 is no visible punch; an empty mask
273
+ * reads all-allowed, which is exactly what makes scale the only knob. It
274
+ * cannot pass through: `punch.allowed` is INDEXED PER SPAN, the new span
275
+ * list has different indices, and the face-only verdict that built the mask
276
+ * cannot be recomputed client-side — a punch on the wrong span (a screen
277
+ * share sliding) is worse than no punch for the preview's duration. The
278
+ * zoom plan, by contrast, IS remapped: its segments are pure output time
279
+ * (`zoomScaleAt` consults nothing but `startSec`/`endSec`), and a revived
280
+ * stretch simply falls outside every segment, which `zoomScaleAt` already
281
+ * renders as the static camera.
282
+ */
283
+ export function retimeForPreview(
284
+ props: RetimeablePreviewProps,
285
+ oldMap: TimeMap,
286
+ newMap: TimeMap,
287
+ ): RetimedPreview {
288
+ const reports: string[] = [];
289
+ const at = (label: string, t: number): number => remapPoint(label, t, oldMap, newMap, reports);
290
+ const fields: RetimedPreviewFields = {
291
+ spans: newMap.spans.map((s) => ({ ...s })),
292
+ outputDurationSec: newMap.outputDuration,
293
+ captionLines: props.captionLines.map((line, i) => ({
294
+ ...line,
295
+ start: at(`caption line ${i + 1} start`, line.start),
296
+ end: at(`caption line ${i + 1} end`, line.end),
297
+ words: line.words.map((w) => ({
298
+ ...w,
299
+ start: at(`caption word "${w.text}" start`, w.start),
300
+ end: at(`caption word "${w.text}" end`, w.end),
301
+ })),
302
+ })),
303
+ sceneCues: props.sceneCues.map((c) => ({
304
+ ...c,
305
+ startSec: at(`scene "${c.id}" start`, c.startSec),
306
+ endSec: at(`scene "${c.id}" end`, c.endSec),
307
+ })),
308
+ punch: { scale: 1, allowed: [] },
309
+ };
310
+ if (props.zoomPlan) {
311
+ fields.zoomPlan = props.zoomPlan.map((seg, i) => ({
312
+ ...seg,
313
+ startSec: at(`zoom segment ${i + 1} start`, seg.startSec),
314
+ endSec: at(`zoom segment ${i + 1} end`, seg.endSec),
315
+ }));
316
+ }
317
+ if (props.ctaWindow) {
318
+ fields.ctaWindow = {
319
+ startSec: at("CTA window start", props.ctaWindow.startSec),
320
+ endSec: at("CTA window end", props.ctaWindow.endSec),
321
+ };
322
+ }
323
+ if (props.sourceTextRegions) {
324
+ fields.sourceTextRegions = props.sourceTextRegions.map((r, i) => ({
325
+ ...r,
326
+ startSec: at(`source text region ${i + 1} start`, r.startSec),
327
+ endSec: at(`source text region ${i + 1} end`, r.endSec),
328
+ }));
329
+ }
330
+ return { fields, reports };
331
+ }
package/src/schema.ts CHANGED
@@ -147,7 +147,26 @@ export const ProductionSchema = z.object({
147
147
  )
148
148
  .optional(),
149
149
  analysis: AnalysisSchema.optional(),
150
+ /**
151
+ * What this run ACTUALLY cut — post cleanup-choices, post user cuts. Every
152
+ * consumer that treats the cutlist as the applied truth (`formatCutReport`,
153
+ * the four NLE exporters, `analyze`'s marker count) reads this one, which
154
+ * is why it stays the resolved list rather than the proposal: recording the
155
+ * proposal here would make each of them re-apply the choices or lie.
156
+ */
150
157
  cutlist: z.array(SegmentSchema).optional(),
158
+ /**
159
+ * The automatic PROPOSAL (cut review step 3) — `buildCutlist`'s output
160
+ * before `applyCleanupChoices` vetoes and before user cuts subtract. Kept
161
+ * alongside because the resolution is lossy: a vetoed removal merges into
162
+ * a plain keep, so `cutlist` alone cannot tell the editor which categories
163
+ * the user declined — its checkboxes and seams re-derive the veto state
164
+ * from THIS list + `overrides.json`'s `cleanup`, through the same
165
+ * `applyCleanupChoices` produce ran. Optional: pre-step-3 files predate it
166
+ * and must still parse (readers fall back to `cutlist`, which back then
167
+ * WAS the proposal plus user cuts).
168
+ */
169
+ cutlistProposed: z.array(SegmentSchema).optional(),
151
170
  /**
152
171
  * Present on a `--clip` run (R19 §93): the target and the resolved window.
153
172
  * `startWord`/`endWord` are indices into the PRE-slice repaired transcript