@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.
- package/LICENSE +27 -0
- package/README.md +20 -0
- package/package.json +29 -0
- package/src/analyze.ts +299 -0
- package/src/assemble.ts +124 -0
- package/src/browser.ts +24 -0
- package/src/captions.ts +92 -0
- package/src/clip.ts +306 -0
- package/src/config.ts +66 -0
- package/src/content-rect-detect.ts +162 -0
- package/src/content-rect.ts +324 -0
- package/src/cover.ts +216 -0
- package/src/cta.ts +68 -0
- package/src/cutlist.ts +170 -0
- package/src/exec.ts +36 -0
- package/src/face.ts +519 -0
- package/src/fill.ts +110 -0
- package/src/framing.ts +277 -0
- package/src/grounding.ts +130 -0
- package/src/index.ts +27 -0
- package/src/ingest.ts +83 -0
- package/src/normalize.ts +397 -0
- package/src/overrides.ts +509 -0
- package/src/phonetics.ts +129 -0
- package/src/producer/anthropic.ts +73 -0
- package/src/producer/beats.ts +330 -0
- package/src/producer/claude-cli.ts +150 -0
- package/src/producer/gemini.ts +197 -0
- package/src/producer/index.ts +217 -0
- package/src/producer/mock.ts +101 -0
- package/src/producer/provider.ts +42 -0
- package/src/producer/repair.ts +474 -0
- package/src/producer/scene-props.ts +212 -0
- package/src/producer/tiered.ts +56 -0
- package/src/producer/usage.ts +426 -0
- package/src/report.ts +36 -0
- package/src/scene-registry.ts +246 -0
- package/src/scene-schema.ts +203 -0
- package/src/schema.ts +177 -0
- package/src/source-text.ts +348 -0
- package/src/timemap.ts +115 -0
- package/src/transcribe.ts +67 -0
- package/src/zoom.ts +154 -0
|
@@ -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";
|