@ossclip/core 0.1.27 → 0.1.29
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/package.json +1 -1
- package/src/browser.ts +63 -2
- package/src/config.ts +15 -0
- package/src/cover-headline.ts +8 -1
- package/src/cutlist.ts +109 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +37 -0
- package/src/producer/antigravity.ts +254 -31
- package/src/producer/beats.ts +45 -4
- package/src/producer/claude-cli.ts +82 -2
- package/src/producer/failure.ts +40 -0
- package/src/producer/fallback.ts +91 -0
- package/src/producer/index.ts +69 -5
- package/src/producer/usage.ts +31 -1
- package/src/recut.ts +6 -1
- package/src/retime-preview.ts +331 -0
- package/src/schema.ts +19 -0
|
@@ -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
|
+
}
|
package/src/producer/index.ts
CHANGED
|
@@ -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,26 @@ export * from "./youtube";
|
|
|
29
30
|
export * from "./scene-props";
|
|
30
31
|
export * from "./repair";
|
|
31
32
|
export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
|
|
32
|
-
|
|
33
|
+
// AGY_PRINT_TIMEOUT is public because the CLI SAYS it: a slow agy call looks
|
|
34
|
+
// identical to a working one on screen, so the spinner names the budget it is
|
|
35
|
+
// waiting out rather than letting the wait read as a freeze (§149). Exported
|
|
36
|
+
// rather than restated in the CLI so the number cannot drift from the flag.
|
|
37
|
+
export { AntigravityProvider, AGY_PRINT_TIMEOUT, type LlmEffort } from "./antigravity";
|
|
33
38
|
export { ClaudeCliProvider } from "./claude-cli";
|
|
34
39
|
export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
35
40
|
export { MockProvider } from "./mock";
|
|
36
41
|
export { TieredProvider } from "./tiered";
|
|
42
|
+
export { FallbackProvider, type FallbackInfo } from "./fallback";
|
|
37
43
|
|
|
38
|
-
export function createProvider(
|
|
44
|
+
export function createProvider(
|
|
45
|
+
name: ProviderName,
|
|
46
|
+
model?: string,
|
|
47
|
+
// A trailing bag, not a third positional per knob: every existing
|
|
48
|
+
// (name, model) call site keeps compiling. Only antigravity reads `effort`
|
|
49
|
+
// today (§143) — the other providers have no such flag, and inventing a
|
|
50
|
+
// mapping for them would be a coercion of intent.
|
|
51
|
+
opts: { effort?: LlmEffort } = {},
|
|
52
|
+
): LlmProvider {
|
|
39
53
|
switch (name) {
|
|
40
54
|
case "claude":
|
|
41
55
|
return new AnthropicProvider(model ?? DEFAULT_CLAUDE_MODEL);
|
|
@@ -48,7 +62,7 @@ export function createProvider(name: ProviderName, model?: string): LlmProvider
|
|
|
48
62
|
// Rides agy's cached subscription sign-in. No default model on purpose:
|
|
49
63
|
// the editorial tier runs whatever the user configured agy itself to
|
|
50
64
|
// use (FINDINGS §132, antigravity provider).
|
|
51
|
-
return new AntigravityProvider(model);
|
|
65
|
+
return new AntigravityProvider(model, undefined, { effort: opts.effort });
|
|
52
66
|
case "mock":
|
|
53
67
|
return new MockProvider();
|
|
54
68
|
}
|
|
@@ -80,6 +94,26 @@ export interface TieringOptions {
|
|
|
80
94
|
* tiering and sends everything to the editorial model.
|
|
81
95
|
*/
|
|
82
96
|
fastModel?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Provider to fall back to when the editorial call TIMES OUT (2026-08-22,
|
|
99
|
+
* FINDINGS §143). Honored only when the primary is antigravity — the one
|
|
100
|
+
* provider measured to hang on the real beat-sheet call. See
|
|
101
|
+
* `fallbackProviderName` for who is eligible.
|
|
102
|
+
*/
|
|
103
|
+
fallback?: ProviderName;
|
|
104
|
+
/**
|
|
105
|
+
* Fired once, before the fallback call — the caller announces it out loud.
|
|
106
|
+
* Silent substitution is the failure mode the fallback exists to avoid.
|
|
107
|
+
*/
|
|
108
|
+
onFallback?: (info: FallbackInfo) => void;
|
|
109
|
+
/**
|
|
110
|
+
* `agy --effort` for the EDITORIAL antigravity call only (§143: exposed
|
|
111
|
+
* after the hang incident — the knob existed and we passed nothing). The
|
|
112
|
+
* mechanical tier keeps agy's default (its small calls never hung), and the
|
|
113
|
+
* §143 fallback never sees it — it is a different provider, and the
|
|
114
|
+
* primary's effort level means nothing to it.
|
|
115
|
+
*/
|
|
116
|
+
effort?: LlmEffort;
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
/**
|
|
@@ -91,7 +125,16 @@ export function createTieredProvider(
|
|
|
91
125
|
name: ProviderName,
|
|
92
126
|
opts: TieringOptions = {},
|
|
93
127
|
): LlmProvider {
|
|
94
|
-
|
|
128
|
+
// The §143 effort knob rides the editorial call only — see TieringOptions.
|
|
129
|
+
let editorial = createProvider(name, opts.model, { effort: opts.effort });
|
|
130
|
+
// Timeout fallback (2026-08-22, FINDINGS §143): only the editorial tier
|
|
131
|
+
// wraps — the beat-sheet call is the one measured to outrun agy's print
|
|
132
|
+
// timeout; mechanical calls are small enough to finish. The fallback gets
|
|
133
|
+
// NO model override: the primary's model name means nothing to a different
|
|
134
|
+
// provider, so the fallback runs its own default.
|
|
135
|
+
if (name === "antigravity" && opts.fallback) {
|
|
136
|
+
editorial = new FallbackProvider(editorial, createProvider(opts.fallback), opts.onFallback);
|
|
137
|
+
}
|
|
95
138
|
const fast = opts.fastModel === "same" ? undefined : opts.fastModel ?? DEFAULT_FAST_MODEL[name];
|
|
96
139
|
if (!fast || fast === opts.model) return editorial;
|
|
97
140
|
return new TieredProvider(editorial, createProvider(name, fast));
|
|
@@ -132,6 +175,27 @@ export function defaultProviderName(
|
|
|
132
175
|
return "claude-cli";
|
|
133
176
|
}
|
|
134
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Where a timed-out antigravity run falls back to (2026-08-22, FINDINGS
|
|
180
|
+
* §143). Only antigravity gets one: it is the sole provider measured to hang
|
|
181
|
+
* persistently on the real beat-sheet call, and handing every provider a
|
|
182
|
+
* second choice would turn one measured incident into a general substitution
|
|
183
|
+
* policy. The order is `defaultProviderName`'s with agy removed — a logged-in
|
|
184
|
+
* claude CLI beats the gemini key for the same §132 subscription-first
|
|
185
|
+
* reasons — and the `hasBin` default of `() => false` keeps this pure the
|
|
186
|
+
* same way: callers that can see a filesystem inject a real checker.
|
|
187
|
+
*/
|
|
188
|
+
export function fallbackProviderName(
|
|
189
|
+
primary: ProviderName,
|
|
190
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
191
|
+
hasBin: (bin: string) => boolean = () => false,
|
|
192
|
+
): ProviderName | undefined {
|
|
193
|
+
if (primary !== "antigravity") return undefined;
|
|
194
|
+
if (hasBin(env.OSSCLIP_CLAUDE_BIN ?? "claude")) return "claude-cli";
|
|
195
|
+
if (env.GEMINI_API_KEY) return "gemini";
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
135
199
|
export interface ProduceScenesResult {
|
|
136
200
|
beatSheet: BeatSheet;
|
|
137
201
|
beatIssues: BeatsValidationIssue[];
|
package/src/producer/usage.ts
CHANGED
|
@@ -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 (${
|
|
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
|