@ossclip/core 0.1.0

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,197 @@
1
+ import { z } from "zod/v4";
2
+ import type { LlmProvider } from "./provider";
3
+ import { estimateTokens, type LlmUsage } from "./usage";
4
+
5
+ export const DEFAULT_GEMINI_MODEL = "gemini-3.6-flash";
6
+
7
+ /**
8
+ * Convert a zod-derived JSON Schema into Gemini's `responseSchema` dialect
9
+ * (R20 §98). The API takes an OpenAPI-style subset — no $refs, no `const`,
10
+ * no `additionalProperties` — so this converts what it can and THROWS on
11
+ * what it cannot; the caller falls back to prompt-stated JSON mode for that
12
+ * call rather than sending a schema the API would reject. The critical
13
+ * schemas (transcript repair, beat sheet, clip beat sheet) are pinned
14
+ * convertible by unit test — those are the calls whose malformed output has
15
+ * actually cost a run (the R20 repair failure).
16
+ */
17
+ export function toGeminiSchema(schema: unknown): Record<string, unknown> {
18
+ if (typeof schema !== "object" || schema === null) {
19
+ throw new Error("not a schema object");
20
+ }
21
+ const s = schema as Record<string, unknown>;
22
+ if (s.$ref !== undefined) throw new Error("$ref unsupported by responseSchema");
23
+ if (s.additionalProperties !== undefined && s.additionalProperties !== false) {
24
+ throw new Error("open records unsupported by responseSchema");
25
+ }
26
+
27
+ // z.literal → {const}: Gemini has no `const`, but a one-value enum is the
28
+ // same constraint.
29
+ if (s.const !== undefined) {
30
+ return { type: typeof s.const === "number" ? "number" : "string", enum: [String(s.const)] };
31
+ }
32
+
33
+ // .nullable() → anyOf [T, {type:"null"}]: fold into `nullable`.
34
+ const variants = (s.anyOf ?? s.oneOf) as unknown[] | undefined;
35
+ if (Array.isArray(variants)) {
36
+ const nonNull = variants.filter(
37
+ (v) => !(typeof v === "object" && v !== null && (v as { type?: string }).type === "null"),
38
+ );
39
+ const hadNull = nonNull.length !== variants.length;
40
+ if (nonNull.length === 1) {
41
+ return { ...toGeminiSchema(nonNull[0]), ...(hadNull ? { nullable: true } : {}) };
42
+ }
43
+ // A union of string literals flattens to one enum; anything else stays
44
+ // an anyOf, which the API accepts for genuine unions.
45
+ const enums = nonNull.map((v) => {
46
+ const c = toGeminiSchema(v);
47
+ return c.type === "string" && Array.isArray(c.enum) ? (c.enum as string[]) : null;
48
+ });
49
+ if (enums.every((e) => e !== null)) {
50
+ return { type: "string", enum: enums.flatMap((e) => e!), ...(hadNull ? { nullable: true } : {}) };
51
+ }
52
+ return { anyOf: nonNull.map(toGeminiSchema), ...(hadNull ? { nullable: true } : {}) };
53
+ }
54
+
55
+ const out: Record<string, unknown> = {};
56
+ if (typeof s.type === "string") out.type = s.type;
57
+ if (typeof s.description === "string") out.description = s.description;
58
+ if (typeof s.format === "string" && ["enum", "date-time"].includes(s.format)) out.format = s.format;
59
+ if (Array.isArray(s.enum)) out.enum = s.enum;
60
+ if (typeof s.minimum === "number") out.minimum = s.minimum;
61
+ if (typeof s.maximum === "number") out.maximum = s.maximum;
62
+ if (s.items !== undefined) out.items = toGeminiSchema(s.items);
63
+ if (typeof s.properties === "object" && s.properties !== null) {
64
+ out.properties = Object.fromEntries(
65
+ Object.entries(s.properties as Record<string, unknown>).map(([k, v]) => [
66
+ k,
67
+ toGeminiSchema(v),
68
+ ]),
69
+ );
70
+ if (Array.isArray(s.required)) out.required = s.required;
71
+ }
72
+ if (out.type === undefined && out.properties === undefined && out.enum === undefined) {
73
+ throw new Error("schema fragment with no representable shape");
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Gemini via the generateContent REST API. JSON output is constrained BOTH
80
+ * ways (R20 §98, from the field failure where a repair response came back
81
+ * as an unterminated string): `responseSchema` makes the decoder emit only
82
+ * schema-valid JSON when the schema converts to the API's subset, and the
83
+ * schema stays stated in the prompt — which is also the whole story for the
84
+ * calls whose schema does not convert. zod remains the authority on the way
85
+ * out either way. A MAX_TOKENS finish is reported as the truncation it is,
86
+ * not as a JSON syntax error at some position.
87
+ * Auth: GEMINI_API_KEY env (PHASE1 §4) or constructor arg.
88
+ */
89
+ export class GeminiProvider implements LlmProvider {
90
+ readonly name = "gemini";
91
+ readonly usage: LlmUsage[] = [];
92
+
93
+ constructor(
94
+ private model: string = DEFAULT_GEMINI_MODEL,
95
+ private apiKey: string | undefined = process.env.GEMINI_API_KEY,
96
+ private baseUrl = "https://generativelanguage.googleapis.com/v1beta",
97
+ ) {}
98
+
99
+ async complete<T>(req: {
100
+ system: string;
101
+ user: string;
102
+ schema: z.ZodType<T>;
103
+ schemaName: string;
104
+ maxTokens?: number;
105
+ }): Promise<T> {
106
+ if (!this.apiKey) throw new Error("GEMINI_API_KEY is not set");
107
+ const jsonSchema = z.toJSONSchema(req.schema);
108
+ const schemaText = JSON.stringify(jsonSchema);
109
+ let responseSchema: Record<string, unknown> | null = null;
110
+ try {
111
+ responseSchema = toGeminiSchema(jsonSchema);
112
+ } catch {
113
+ // Not convertible (records, refs) — prompt-stated JSON mode carries it.
114
+ }
115
+ const maxOutputTokens = req.maxTokens ?? 16000;
116
+ const body = {
117
+ system_instruction: { parts: [{ text: req.system }] },
118
+ contents: [
119
+ {
120
+ role: "user",
121
+ parts: [
122
+ {
123
+ text:
124
+ `${req.user}\n\n` +
125
+ `Respond with ONLY a JSON object valid against this JSON Schema ` +
126
+ `("${req.schemaName}"):\n${schemaText}`,
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ generationConfig: {
132
+ responseMimeType: "application/json",
133
+ ...(responseSchema ? { responseSchema } : {}),
134
+ maxOutputTokens,
135
+ },
136
+ };
137
+ const started = Date.now();
138
+ const res = await fetch(
139
+ `${this.baseUrl}/models/${this.model}:generateContent?key=${this.apiKey}`,
140
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
141
+ );
142
+ if (!res.ok) {
143
+ throw new Error(`gemini request failed: ${res.status} ${(await res.text()).slice(0, 300)}`);
144
+ }
145
+ const data = (await res.json()) as {
146
+ candidates?: Array<{ finishReason?: string; content?: { parts?: Array<{ text?: string }> } }>;
147
+ usageMetadata?: {
148
+ promptTokenCount?: number;
149
+ candidatesTokenCount?: number;
150
+ cachedContentTokenCount?: number;
151
+ // Thinking tokens are billed as output but reported apart from it.
152
+ thoughtsTokenCount?: number;
153
+ };
154
+ };
155
+ const text = data.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
156
+ const meta = data.usageMetadata;
157
+ // Recorded before the failure checks — a truncated response still
158
+ // consumed tokens, and a run that fell over is exactly when the cost of
159
+ // getting there matters (same rule the Anthropic provider follows).
160
+ this.usage.push({
161
+ provider: this.name,
162
+ model: this.model,
163
+ schemaName: req.schemaName,
164
+ inputTokens: meta?.promptTokenCount ?? estimateTokens(`${req.system}${req.user}`),
165
+ outputTokens:
166
+ meta?.candidatesTokenCount === undefined
167
+ ? estimateTokens(text ?? "")
168
+ : meta.candidatesTokenCount + (meta.thoughtsTokenCount ?? 0),
169
+ cachedInputTokens: meta?.cachedContentTokenCount,
170
+ exact: meta?.promptTokenCount !== undefined,
171
+ billed: true,
172
+ ms: Date.now() - started,
173
+ });
174
+ const finish = data.candidates?.[0]?.finishReason;
175
+ if (finish === "MAX_TOKENS") {
176
+ // On a thinking model the thought tokens draw from the same budget, so
177
+ // the visible JSON can be cut off long before maxOutputTokens reads
178
+ // "spent" — say so instead of failing as a syntax error mid-string.
179
+ throw new Error(
180
+ `gemini output truncated at maxOutputTokens (${maxOutputTokens}` +
181
+ `${meta?.thoughtsTokenCount ? `, ${meta.thoughtsTokenCount} of it thinking` : ""}) — ` +
182
+ "the call needs a bigger budget",
183
+ );
184
+ }
185
+ if (!text) throw new Error(`gemini returned no text candidate (finishReason ${finish ?? "?"})`);
186
+ let parsed: unknown;
187
+ try {
188
+ parsed = JSON.parse(text);
189
+ } catch (err) {
190
+ throw new Error(
191
+ `gemini returned invalid JSON (finishReason ${finish ?? "?"}): ` +
192
+ `${err instanceof Error ? err.message : String(err)} — head: ${text.slice(0, 120)}`,
193
+ );
194
+ }
195
+ return req.schema.parse(parsed);
196
+ }
197
+ }
@@ -0,0 +1,217 @@
1
+ import type { Transcript } from "../schema";
2
+ import type { Scene, SceneComponentId } from "../scene-schema";
3
+ import type { LlmProvider, ProviderName } from "./provider";
4
+ import { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
5
+ import { ClaudeCliProvider } from "./claude-cli";
6
+ import { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
7
+ import { MockProvider } from "./mock";
8
+ import { TieredProvider } from "./tiered";
9
+ import {
10
+ generateBeatSheet,
11
+ normalizeBeatSheet,
12
+ type BeatSheet,
13
+ type BeatsValidationIssue,
14
+ } from "./beats";
15
+ import { generateScenes, type ScenePropsFailure } from "./scene-props";
16
+ import { buildFramingBrief, repairMomentLayouts, type FramingContext } from "../framing";
17
+ import {
18
+ resolveClipWindow,
19
+ sliceMoments,
20
+ sliceTranscript,
21
+ type ClipWindow,
22
+ } from "../clip";
23
+
24
+ export * from "./provider";
25
+ export * from "./usage";
26
+ export * from "./beats";
27
+ export * from "./scene-props";
28
+ export * from "./repair";
29
+ export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
30
+ export { ClaudeCliProvider } from "./claude-cli";
31
+ export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
32
+ export { MockProvider } from "./mock";
33
+ export { TieredProvider } from "./tiered";
34
+
35
+ export function createProvider(name: ProviderName, model?: string): LlmProvider {
36
+ switch (name) {
37
+ case "claude":
38
+ return new AnthropicProvider(model ?? DEFAULT_CLAUDE_MODEL);
39
+ case "claude-cli":
40
+ // Rides the Claude Code subscription (Pro/Max) — no API key involved.
41
+ return new ClaudeCliProvider(model);
42
+ case "gemini":
43
+ return new GeminiProvider(model ?? DEFAULT_GEMINI_MODEL);
44
+ case "mock":
45
+ return new MockProvider();
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The small model each provider reaches for on mechanical calls. Deliberately
51
+ * a same-family sibling of the default rather than a cross-vendor pick, so
52
+ * tiering changes cost without also changing who you are talking to. Override
53
+ * with `--llm-fast-model` (or `fastModel` in the config) — for a model this
54
+ * code has never heard of, that flag is the whole interface.
55
+ */
56
+ export const DEFAULT_FAST_MODEL: Partial<Record<ProviderName, string>> = {
57
+ claude: "claude-haiku-4-5-20251001",
58
+ "claude-cli": "claude-haiku-4-5-20251001",
59
+ gemini: "gemini-3.5-flash-lite",
60
+ };
61
+
62
+ export interface TieringOptions {
63
+ /** Model for the editorial call (the beat sheet). */
64
+ model?: string;
65
+ /**
66
+ * Model for mechanical calls (repair, scene props). `"same"` disables
67
+ * tiering and sends everything to the editorial model.
68
+ */
69
+ fastModel?: string;
70
+ }
71
+
72
+ /**
73
+ * A provider that sizes the model to the call (FINDINGS §37). Falls back to a
74
+ * single un-tiered provider when the two models resolve to the same thing, so
75
+ * `usage` stays a plain log and nothing wraps for no reason.
76
+ */
77
+ export function createTieredProvider(
78
+ name: ProviderName,
79
+ opts: TieringOptions = {},
80
+ ): LlmProvider {
81
+ const editorial = createProvider(name, opts.model);
82
+ const fast = opts.fastModel === "same" ? undefined : opts.fastModel ?? DEFAULT_FAST_MODEL[name];
83
+ if (!fast || fast === opts.model) return editorial;
84
+ return new TieredProvider(editorial, createProvider(name, fast));
85
+ }
86
+
87
+ /**
88
+ * Default provider when --llm isn't given, in preference order.
89
+ *
90
+ * Gemini leads on measured evidence, not vendor preference: on the same clip
91
+ * it ran 3,540 input tokens against the Claude CLI's 83,378 — the CLI re-sends
92
+ * its whole harness prefix per invocation — for ~$0.05 against ~$0.85 and 27s
93
+ * against 171s, with editorial output that held up. Both models recovered the
94
+ * mishearing that matters ("coach and" → "code churn"); Claude is stronger only
95
+ * at recovering a mangled PROPER NOUN, which `--speaker` addresses directly.
96
+ *
97
+ * Falling back to the Claude Code CLI last keeps the no-keys-configured path
98
+ * working on a Pro/Max subscription rather than failing.
99
+ */
100
+ export function defaultProviderName(env: NodeJS.ProcessEnv = process.env): ProviderName {
101
+ if (env.GEMINI_API_KEY) return "gemini";
102
+ if (env.ANTHROPIC_API_KEY) return "claude";
103
+ return "claude-cli";
104
+ }
105
+
106
+ export interface ProduceScenesResult {
107
+ beatSheet: BeatSheet;
108
+ beatIssues: BeatsValidationIssue[];
109
+ scenes: Scene[];
110
+ failures: ScenePropsFailure[];
111
+ /**
112
+ * Present on a `--clip` run (R19 §93): the resolved window (pre-slice index
113
+ * space + source seconds), the transcript sliced to it — which is the space
114
+ * the returned moments and scenes live in — and what resolution changed
115
+ * about the model's raw pick.
116
+ */
117
+ clip?: { window: ClipWindow; transcript: Transcript; notes: string[] };
118
+ }
119
+
120
+ /** The full producer-brain pipeline: beat sheet → per-moment scene props. */
121
+ export async function produceScenes(
122
+ provider: LlmProvider,
123
+ args: {
124
+ transcript: Transcript;
125
+ outputDuration: number;
126
+ intent?: string;
127
+ /** Who is on camera — see `--speaker`. */
128
+ speaker?: string;
129
+ /**
130
+ * Debug: render every graphic moment with this component instead of the
131
+ * one the producer picked. Exists because a component the producer never
132
+ * chooses is a component never tested on real copy — FlowDiagram went
133
+ * three rounds unexercised (FINDINGS §20).
134
+ */
135
+ forceComponent?: SceneComponentId;
136
+ /**
137
+ * Camera-framing constraints (PLAN Tasks A+B), present when the source
138
+ * went through normalization. Feeds the beat-sheet prompt (the brief) AND
139
+ * the repair pass that enforces it — ship both, trust neither alone.
140
+ */
141
+ framing?: FramingContext;
142
+ /**
143
+ * `--clip` (R19 §93): select ONE ~targetSec window and plan only inside
144
+ * it. The window request rides the beat-sheet call (§93d); the returned
145
+ * moments/scenes are re-anchored to the sliced transcript, and the caller
146
+ * slices the rest of its pipeline state to `clip.window`.
147
+ */
148
+ clip?: { targetSec: number };
149
+ /** Output frame shape (R21 §101) — landscape gets layout-variety
150
+ * guidance in the beat prompt. Omitted = portrait, no extra text. */
151
+ aspect?: "9:16" | "16:9";
152
+ },
153
+ ): Promise<ProduceScenesResult> {
154
+ const framingBrief = args.framing
155
+ ? buildFramingBrief(args.framing, args.transcript)
156
+ : undefined;
157
+ const { sheet, issues, highlight } = await generateBeatSheet(
158
+ provider,
159
+ args.transcript,
160
+ args.outputDuration,
161
+ args.intent,
162
+ args.speaker,
163
+ framingBrief || undefined,
164
+ args.clip,
165
+ args.aspect,
166
+ );
167
+
168
+ // ---- Clip window (R19 §93) ----------------------------------------------
169
+ // Resolve (validate + sentence-snap) the highlight, slice the transcript,
170
+ // and re-anchor the moments into the slice. Then re-run normalization
171
+ // against the SLICED transcript: the coverage budget and variety passes
172
+ // were computed against the full take's runtime above, and a 60s window
173
+ // deserves a 60s window's graphics schedule.
174
+ let transcript = args.transcript;
175
+ let workingSheet = sheet;
176
+ let clip: ProduceScenesResult["clip"];
177
+ if (args.clip) {
178
+ const resolved = resolveClipWindow(args.transcript, highlight, args.clip.targetSec);
179
+ transcript = sliceTranscript(args.transcript, resolved.window);
180
+ const anchored = sliceMoments(sheet.moments, resolved.window);
181
+ if (anchored.length === 0) {
182
+ issues.push({
183
+ moment: -1,
184
+ issue: "no moments inside the highlight — the clip renders as a plain captioned take",
185
+ });
186
+ }
187
+ const renorm = normalizeBeatSheet(
188
+ { hook: sheet.hook, coverText: sheet.coverText, moments: anchored },
189
+ transcript,
190
+ );
191
+ workingSheet = renorm.sheet;
192
+ issues.push(...renorm.issues);
193
+ clip = { window: resolved.window, transcript, notes: resolved.notes };
194
+ }
195
+
196
+ // Applied AFTER normalization: the coverage budget and variety passes may
197
+ // demote moments to "none", and forcing before them can leave nothing to
198
+ // render — the flag would appear to work and produce no scenes at all.
199
+ // The forced component drops the producer's layout too: it was chosen for
200
+ // a different component and may not even be in the forced one's repertoire.
201
+ let moments = args.forceComponent
202
+ ? workingSheet.moments.map((m) =>
203
+ m.sceneKind === "none" ? m : { ...m, sceneKind: args.forceComponent!, layout: undefined },
204
+ )
205
+ : workingSheet.moments;
206
+ // The safety net (Task B): whatever the prompt did, no moment leaves here
207
+ // with a layout that would crop the head at its own moment's framing.
208
+ if (args.framing) {
209
+ const repaired = repairMomentLayouts(moments, transcript, args.framing);
210
+ moments = repaired.moments;
211
+ issues.push(...repaired.issues);
212
+ }
213
+ const { scenes, failures } = await generateScenes(provider, moments, transcript, {
214
+ framing: args.framing,
215
+ });
216
+ return { beatSheet: { ...workingSheet, moments }, beatIssues: issues, scenes, failures, clip };
217
+ }
@@ -0,0 +1,101 @@
1
+ import type { z } from "zod/v4";
2
+ import type { LlmProvider } from "./provider";
3
+ import { estimateTokens, type LlmUsage } from "./usage";
4
+ import { BeatSheetSchema } from "./beats";
5
+
6
+ /**
7
+ * Deterministic offline producer: segments the transcript into fixed-size
8
+ * moments and cycles through a few scene kinds. Exists so the whole
9
+ * produce path is exercisable with zero network (PHASE1 §4 "offline path is
10
+ * first-class") — and so tests can run the exact code path `--produce` runs.
11
+ */
12
+ export class MockProvider implements LlmProvider {
13
+ readonly name = "mock";
14
+ readonly usage: LlmUsage[] = [];
15
+
16
+ async complete<T>(req: {
17
+ system: string;
18
+ user: string;
19
+ schema: z.ZodType<T>;
20
+ schemaName: string;
21
+ }): Promise<T> {
22
+ const result =
23
+ req.schemaName === "beat_sheet" || req.schemaName === "clip_beat_sheet"
24
+ ? this.beatSheet(req.user, req.schema, req.schemaName === "clip_beat_sheet")
25
+ : req.schemaName === "transcript_repair"
26
+ ? // A deterministic no-op: the offline path must exercise the repair
27
+ // call without inventing corrections a real provider would justify.
28
+ req.schema.parse({ repairs: [] })
29
+ : this.sceneProps(req.user, req.schema, req.schemaName);
30
+ // Estimated, and costing exactly nothing — but recorded, because the
31
+ // offline path is first-class and "how big are the prompts this pipeline
32
+ // sends" is worth answering without spending anything to find out.
33
+ this.usage.push({
34
+ provider: this.name,
35
+ schemaName: req.schemaName,
36
+ inputTokens: estimateTokens(`${req.system}\n${req.user}`),
37
+ outputTokens: estimateTokens(JSON.stringify(result)),
38
+ reportedCostUsd: 0,
39
+ exact: false,
40
+ billed: false,
41
+ ms: 0,
42
+ });
43
+ return result;
44
+ }
45
+
46
+ private beatSheet<T>(user: string, schema: z.ZodType<T>, clip: boolean): T {
47
+ const wordCount = (user.match(/\[\d+\]/g) ?? []).length;
48
+ // Clip mode (R19 §93): a deterministic highlight — a stretch starting 40%
49
+ // in, sized at ~2.8 words/sec of the requested target. No timestamps here,
50
+ // so `resolveClipWindow` does the real fitting (trim + sentence snap) on
51
+ // the actual word stamps downstream.
52
+ const targetSec = Number.parseFloat(/Target clip length: ~(\d+(?:\.\d+)?)s/.exec(user)?.[1] ?? "60");
53
+ const winStart = clip ? Math.min(Math.floor(wordCount * 0.4), Math.max(0, wordCount - 2)) : 0;
54
+ const winEnd = clip
55
+ ? Math.min(wordCount - 1, winStart + Math.max(3, Math.round(targetSec * 2.8)))
56
+ : wordCount - 1;
57
+ const kinds = ["TitleCard", "none", "StatCard", "none", "FlowDiagram", "none", "RuleCard"] as const;
58
+ const per = Math.max(3, Math.ceil((winEnd - winStart + 1) / 6));
59
+ const moments = [];
60
+ for (let start = winStart, k = 0; start <= winEnd; start += per, k++) {
61
+ moments.push({
62
+ startWord: start,
63
+ endWord: Math.min(start + per - 1, winEnd),
64
+ purpose: `beat ${k + 1}`,
65
+ onScreenCopy: `BEAT ${k + 1}`,
66
+ sceneKind: kinds[k % kinds.length]!,
67
+ });
68
+ if (moments.length >= 8) break;
69
+ }
70
+ const sheet = {
71
+ hook: "MOCK HOOK",
72
+ moments,
73
+ ...(clip
74
+ ? { highlight: { startWord: winStart, endWord: winEnd, reason: "mock: fixed 40%-in window" } }
75
+ : {}),
76
+ };
77
+ return schema.parse(sheet);
78
+ }
79
+
80
+ private sceneProps<T>(_user: string, schema: z.ZodType<T>, schemaName: string): T {
81
+ const canned: Record<string, unknown> = {
82
+ TitleCard_props: { eyebrow: "MOCK", title: "THE RAW TAKE", emphasis: "861%", sub: "becomes a clean edit" },
83
+ StatCard_props: { label: "FILLERS REMOVED", value: "+100%", caption: "MORE SIGNAL. LESS UM.", inverted: true },
84
+ RuleCard_props: { kicker: "PRODUCER RULE", text: "SHOW, THEN TELL", struck: "NOT: WALLS OF TEXT" },
85
+ StrikethroughReveal_props: { lines: [{ text: "MORE WORDS", struck: true }, { text: "MORE SIGNAL", struck: false }] },
86
+ FlowDiagram_props: { nodes: ["RAW TAKE", "CUT", "PRODUCED"], emphasizeLast: true },
87
+ TerminalMock_props: {
88
+ windows: [
89
+ { title: "ossclip-01", lines: ["$ ossclip produce raw.mp4", "▸ transcribing…", "▸ cutting…"] },
90
+ { title: "ossclip-02", lines: ["▸ rendering…", "✓ done"] },
91
+ ],
92
+ fanOut: "OUTPUT ×1",
93
+ },
94
+ ChatMock_props: { messages: [{ from: "user", text: "can it cut my ums?" }, { from: "agent", text: "already did." }] },
95
+ ScreenshotFrame_props: { label: "REVIEW STATUS: TODAY", kenBurns: true },
96
+ };
97
+ const props = canned[schemaName];
98
+ if (!props) throw new Error(`mock provider: unknown schema ${schemaName}`);
99
+ return schema.parse(props);
100
+ }
101
+ }
@@ -0,0 +1,42 @@
1
+ import type { z } from "zod/v4";
2
+ import type { LlmUsage } from "./usage";
3
+
4
+ /**
5
+ * The one seam between ossclip and any LLM. Implementations must return a
6
+ * value that already validates against `schema` (they may use native
7
+ * structured output or parse-and-validate); on failure they throw.
8
+ * No provider types leak past this interface (PHASE1 §4).
9
+ */
10
+ /**
11
+ * Which kind of thinking a call needs.
12
+ *
13
+ * `editorial` is the beat sheet — picking the hook, segmenting the take,
14
+ * writing the copy. That is the judgement the whole product rests on.
15
+ * `mechanical` is everything else: repairing a mishearing, filling props
16
+ * against a schema. Both are cheap to check and expensive to over-buy — on the
17
+ * CLI path a call costs ~$0.26 on the top model and ~$0.04 on the small one,
18
+ * and the harness prefix dominates either way (FINDINGS §37).
19
+ */
20
+ export type CallTier = "editorial" | "mechanical";
21
+
22
+ export interface LlmProvider {
23
+ readonly name: string;
24
+ /**
25
+ * One record per completed call, in call order — tokens and timing, never
26
+ * money (pricing lives in `usage.ts`). Producing a video is a repair pass, a
27
+ * beat sheet and one call per scene, so this is how a run answers "what did
28
+ * that cost". Providers append; nobody else writes to it.
29
+ */
30
+ readonly usage: readonly LlmUsage[];
31
+ complete<T>(req: {
32
+ system: string;
33
+ user: string;
34
+ schema: z.ZodType<T>;
35
+ schemaName: string;
36
+ maxTokens?: number;
37
+ /** Defaults to `editorial` — a caller that says nothing gets the good model. */
38
+ tier?: CallTier;
39
+ }): Promise<T>;
40
+ }
41
+
42
+ export type ProviderName = "claude" | "claude-cli" | "gemini" | "mock";