@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,246 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { type Layout, type SceneComponentId } from "./scene-schema";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The scene library's contract, React-free so both the producer brain and the
|
|
6
|
+
* render layer share one source of truth. Taste lives in the components; the
|
|
7
|
+
* LLM only fills these props. Copy caps enforce the virality grammar
|
|
8
|
+
* (short, numeric, high-contrast — BRAINSTORM §4.5).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const TitleCardProps = z.object({
|
|
12
|
+
eyebrow: z.string().max(28).optional(),
|
|
13
|
+
title: z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.max(48)
|
|
17
|
+
.describe("the claim WITHOUT the emphasis token — never repeat the emphasis here"),
|
|
18
|
+
/** A huge emphasized token — a number or 1–2 punch words ("861%"). */
|
|
19
|
+
emphasis: z
|
|
20
|
+
.string()
|
|
21
|
+
.max(16)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("the number/punch token pulled OUT of the title (e.g. '861%') — not a duplicate of it"),
|
|
24
|
+
sub: z.string().max(64).optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const StatCardProps = z.object({
|
|
28
|
+
label: z.string().min(1).max(28),
|
|
29
|
+
value: z.string().min(1).max(12),
|
|
30
|
+
caption: z.string().max(40).optional(),
|
|
31
|
+
/** Inverted emphasis block (light card on dark stage) for the punch stat. */
|
|
32
|
+
inverted: z.boolean().default(false),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const RuleCardProps = z.object({
|
|
36
|
+
kicker: z.string().min(1).max(24),
|
|
37
|
+
text: z.string().min(1).max(40),
|
|
38
|
+
/** Rendered below, struck through — the rejected alternative. */
|
|
39
|
+
struck: z.string().max(40).optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const StrikethroughRevealProps = z.object({
|
|
43
|
+
lines: z
|
|
44
|
+
.array(
|
|
45
|
+
z.object({
|
|
46
|
+
text: z.string().min(1).max(32),
|
|
47
|
+
struck: z.boolean().default(false),
|
|
48
|
+
/**
|
|
49
|
+
* Verdict glyph before the line (R16 §66): `cross` renders ✗ in the
|
|
50
|
+
* danger color, `check` ✓ in the success color — the wrong-vs-right
|
|
51
|
+
* list variant. Defaults to none, so every existing production
|
|
52
|
+
* renders byte-identically.
|
|
53
|
+
*/
|
|
54
|
+
mark: z.enum(["none", "cross", "check"]).default("none"),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(4),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An enumeration (R16 §67): the speaker lists parallel things and each gets a
|
|
63
|
+
* bullet row. Born from a real miss — "what you need is AI harness, context
|
|
64
|
+
* engineering, prompt engineering" was bent into a title-plus-strike card
|
|
65
|
+
* that struck a thing the speaker RECOMMENDED, because no component said
|
|
66
|
+
* "list".
|
|
67
|
+
*/
|
|
68
|
+
export const BulletListProps = z.object({
|
|
69
|
+
/** Small kicker above the list ("WHAT YOU NEED"). */
|
|
70
|
+
title: z.string().max(28).optional(),
|
|
71
|
+
items: z.array(z.string().min(1).max(36)).min(2).max(5),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const FlowDiagramProps = z.object({
|
|
75
|
+
nodes: z.array(z.string().min(1).max(16)).min(2).max(5),
|
|
76
|
+
/** Emphasize the terminal node (white chip, like CHURN in the reference). */
|
|
77
|
+
emphasizeLast: z.boolean().default(true),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const TerminalMockProps = z.object({
|
|
81
|
+
windows: z
|
|
82
|
+
.array(
|
|
83
|
+
z.object({
|
|
84
|
+
title: z.string().max(24),
|
|
85
|
+
lines: z.array(z.string().max(40)).min(1).max(6),
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(5),
|
|
90
|
+
/** Fan-out label under the windows ("OUTPUT ×1"). */
|
|
91
|
+
fanOut: z.string().max(20).optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const ChatMockProps = z.object({
|
|
95
|
+
messages: z
|
|
96
|
+
.array(
|
|
97
|
+
z.object({
|
|
98
|
+
from: z.enum(["user", "agent"]),
|
|
99
|
+
text: z.string().min(1).max(60),
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
.min(1)
|
|
103
|
+
.max(4),
|
|
104
|
+
/**
|
|
105
|
+
* The comment-CTA word the viewer is asked to type, plain and unformatted —
|
|
106
|
+
* the render quotes and capitalizes it ('"AGENTS"'), because formatting never
|
|
107
|
+
* lives in LLM output (FINDINGS §16). Setting it also collapses the scene to
|
|
108
|
+
* that ONE bubble (FINDINGS §28b), so it is only correct when the ask really
|
|
109
|
+
* is "comment <word>". A "reply with a number / which one" ask has no word to
|
|
110
|
+
* type and must leave this unset — `rejectCtaKeyword` drops it if it slips
|
|
111
|
+
* through, and the exchange in `messages` renders instead.
|
|
112
|
+
*/
|
|
113
|
+
keyword: z
|
|
114
|
+
.string()
|
|
115
|
+
.max(16)
|
|
116
|
+
.optional()
|
|
117
|
+
.describe(
|
|
118
|
+
"ONLY for a 'comment <word>' ask: the single distinctive word the viewer types, " +
|
|
119
|
+
"plain and unformatted. Leave unset for any other CTA — especially " +
|
|
120
|
+
"'reply with a number/which one', where there is no word to type. Never a " +
|
|
121
|
+
"referential filler like 'number', 'answer', 'comment' or 'below'.",
|
|
122
|
+
),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const ScreenshotFrameProps = z.object({
|
|
126
|
+
/** File name inside the render public dir; omitted → styled placeholder frame. */
|
|
127
|
+
src: z.string().optional(),
|
|
128
|
+
label: z.string().max(32).optional(),
|
|
129
|
+
kenBurns: z.boolean().default(true),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export interface SceneComponentMeta {
|
|
133
|
+
propsSchema: z.ZodTypeAny;
|
|
134
|
+
defaultProps: Record<string, unknown>;
|
|
135
|
+
defaultLayout: Layout;
|
|
136
|
+
/**
|
|
137
|
+
* Layouts a REPEAT of this component may use instead, so the same card
|
|
138
|
+
* treatment twice in one video doesn't read as a template (FINDINGS §20).
|
|
139
|
+
* Varying the layout is safe where swapping the component is not — layout
|
|
140
|
+
* is presentation, while a component swap is an editorial judgement that
|
|
141
|
+
* can demand props the beat has no material for (a StatCard needs a number).
|
|
142
|
+
*
|
|
143
|
+
* Invariant, property-tested: an alternate's graphic slot is never SHORTER
|
|
144
|
+
* than the default's. Components size their type against their default slot
|
|
145
|
+
* — FlowDiagram literally budgets against `graphic-only` — so moving one
|
|
146
|
+
* into a smaller slot would re-open the overflow bug of §1/§12. Components
|
|
147
|
+
* that already sit in the tallest slot therefore have no alternate.
|
|
148
|
+
*/
|
|
149
|
+
altLayouts: Layout[];
|
|
150
|
+
/** One-liner the producer prompt uses to pick components. */
|
|
151
|
+
whenToUse: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const SCENE_REGISTRY: Record<SceneComponentId, SceneComponentMeta> = {
|
|
155
|
+
TitleCard: {
|
|
156
|
+
propsSchema: TitleCardProps,
|
|
157
|
+
defaultProps: { title: "TITLE" },
|
|
158
|
+
defaultLayout: "pip-bubble",
|
|
159
|
+
altLayouts: [],
|
|
160
|
+
whenToUse:
|
|
161
|
+
"The core claim or hook as big typography; use `emphasis` for a huge number or punch word.",
|
|
162
|
+
},
|
|
163
|
+
// Layout mix policy (FINDINGS §4): the speaker's face is the product.
|
|
164
|
+
// Stat/Rule cards sit UNDER a big face (video-top, the reference's
|
|
165
|
+
// signature frame); only TitleCard demotes it to a bubble, and only the
|
|
166
|
+
// diagram/terminal mocks may take the frame alone — briefly.
|
|
167
|
+
StatCard: {
|
|
168
|
+
propsSchema: StatCardProps,
|
|
169
|
+
defaultProps: { label: "METRIC", value: "+0%" },
|
|
170
|
+
defaultLayout: "video-top",
|
|
171
|
+
altLayouts: ["blurred-behind"],
|
|
172
|
+
whenToUse: "One striking metric (value like '+242%', '×3', '5s'); punchline in `caption`.",
|
|
173
|
+
},
|
|
174
|
+
RuleCard: {
|
|
175
|
+
propsSchema: RuleCardProps,
|
|
176
|
+
defaultProps: { kicker: "RULE", text: "DO THE THING" },
|
|
177
|
+
defaultLayout: "video-top",
|
|
178
|
+
altLayouts: ["blurred-behind"],
|
|
179
|
+
whenToUse:
|
|
180
|
+
"A prescriptive takeaway ('CAPACITY RULE / CAP ACTIVE AGENTS'); `struck` shows the rejected alternative.",
|
|
181
|
+
},
|
|
182
|
+
StrikethroughReveal: {
|
|
183
|
+
propsSchema: StrikethroughRevealProps,
|
|
184
|
+
defaultProps: { lines: [{ text: "NOT THIS", struck: true }] },
|
|
185
|
+
defaultLayout: "blurred-behind",
|
|
186
|
+
altLayouts: ["graphic-only"],
|
|
187
|
+
whenToUse:
|
|
188
|
+
"Negation/contrast beat — big words over the blurred speaker. Strike EVERY line of the " +
|
|
189
|
+
"phrase the speaker negates, not just its tail — a half-struck claim reads as a typo. " +
|
|
190
|
+
'For wrong-vs-right lists, set mark: "cross"/"check" (✗/✓) per line instead.',
|
|
191
|
+
},
|
|
192
|
+
FlowDiagram: {
|
|
193
|
+
propsSchema: FlowDiagramProps,
|
|
194
|
+
defaultProps: { nodes: ["A", "B"] },
|
|
195
|
+
defaultLayout: "graphic-only",
|
|
196
|
+
altLayouts: [],
|
|
197
|
+
whenToUse: "A causal chain or pipeline as chips with arrows (TEAM → AI AGENTS → CHURN).",
|
|
198
|
+
},
|
|
199
|
+
TerminalMock: {
|
|
200
|
+
propsSchema: TerminalMockProps,
|
|
201
|
+
defaultProps: { windows: [{ title: "terminal-01", lines: ["$ run"] }] },
|
|
202
|
+
defaultLayout: "graphic-only",
|
|
203
|
+
altLayouts: [],
|
|
204
|
+
whenToUse: "Anything about running code/processes/agents — stylized terminal windows.",
|
|
205
|
+
},
|
|
206
|
+
ChatMock: {
|
|
207
|
+
propsSchema: ChatMockProps,
|
|
208
|
+
defaultProps: { messages: [{ from: "user", text: "hello" }] },
|
|
209
|
+
defaultLayout: "blurred-behind",
|
|
210
|
+
altLayouts: ["graphic-only"],
|
|
211
|
+
whenToUse:
|
|
212
|
+
"A quoted phrase or exchange as chat bubbles over the blurred speaker; for a comment-CTA beat, set `keyword` to the word viewers should type.",
|
|
213
|
+
},
|
|
214
|
+
ScreenshotFrame: {
|
|
215
|
+
propsSchema: ScreenshotFrameProps,
|
|
216
|
+
defaultProps: {},
|
|
217
|
+
defaultLayout: "video-top",
|
|
218
|
+
altLayouts: ["blurred-behind"],
|
|
219
|
+
whenToUse: "Reference to a document/PR/review — a framed screenshot look with a label chip.",
|
|
220
|
+
},
|
|
221
|
+
BulletList: {
|
|
222
|
+
propsSchema: BulletListProps,
|
|
223
|
+
defaultProps: { items: ["FIRST", "SECOND"] },
|
|
224
|
+
defaultLayout: "blurred-behind",
|
|
225
|
+
altLayouts: ["graphic-only"],
|
|
226
|
+
whenToUse:
|
|
227
|
+
"An ENUMERATION — the speaker lists two or more parallel things " +
|
|
228
|
+
"('what you need is X, Y, Z'). One bullet per item, optional kicker title. " +
|
|
229
|
+
"Use this for lists instead of bending TitleCard or StrikethroughReveal around them.",
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolution order per PHASE1 §2: componentDefaults ← props ← overrides.
|
|
235
|
+
* Returns null when the merged result doesn't validate.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveSceneProps(
|
|
238
|
+
component: SceneComponentId,
|
|
239
|
+
props: Record<string, unknown>,
|
|
240
|
+
overrides: Record<string, unknown> = {},
|
|
241
|
+
): Record<string, unknown> | null {
|
|
242
|
+
const meta = SCENE_REGISTRY[component];
|
|
243
|
+
const merged = { ...meta.defaultProps, ...props, ...overrides };
|
|
244
|
+
const parsed = meta.propsSchema.safeParse(merged);
|
|
245
|
+
return parsed.success ? (parsed.data as Record<string, unknown>) : null;
|
|
246
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How the stage is arranged while a scene is active (BRAINSTORM §4.6, PHASE1
|
|
5
|
+
* §1). `lower-third` and the two `split-*` layouts are landscape-native
|
|
6
|
+
* additions (R15 §54) — the frame-aware slot table gives every layout
|
|
7
|
+
* geometry in BOTH aspects (the split axis follows the frame's long edge:
|
|
8
|
+
* side-by-side in 16:9, stacked in 9:16), so the editor's layout switch can
|
|
9
|
+
* never render nothing.
|
|
10
|
+
*/
|
|
11
|
+
export const LayoutSchema = z.enum([
|
|
12
|
+
"full-bleed",
|
|
13
|
+
"video-top",
|
|
14
|
+
"pip-bubble",
|
|
15
|
+
"graphic-only",
|
|
16
|
+
"blurred-behind",
|
|
17
|
+
"lower-third",
|
|
18
|
+
"split-left",
|
|
19
|
+
"split-right",
|
|
20
|
+
]);
|
|
21
|
+
export type Layout = z.infer<typeof LayoutSchema>;
|
|
22
|
+
|
|
23
|
+
export const SceneComponentIdSchema = z.enum([
|
|
24
|
+
"TitleCard",
|
|
25
|
+
"StatCard",
|
|
26
|
+
"RuleCard",
|
|
27
|
+
"StrikethroughReveal",
|
|
28
|
+
"FlowDiagram",
|
|
29
|
+
"TerminalMock",
|
|
30
|
+
"ChatMock",
|
|
31
|
+
"ScreenshotFrame",
|
|
32
|
+
"BulletList",
|
|
33
|
+
]);
|
|
34
|
+
export type SceneComponentId = z.infer<typeof SceneComponentIdSchema>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where a scene sits — anchored to transcript word indices, never seconds,
|
|
38
|
+
* so a cleanup-level change re-resolves cleanly (PHASE1 §5).
|
|
39
|
+
*/
|
|
40
|
+
export const SceneAnchorSchema = z.object({
|
|
41
|
+
startWord: z.number().int().nonnegative(),
|
|
42
|
+
endWord: z.number().int().nonnegative(),
|
|
43
|
+
});
|
|
44
|
+
export type SceneAnchor = z.infer<typeof SceneAnchorSchema>;
|
|
45
|
+
|
|
46
|
+
export const SceneSchema = z.object({
|
|
47
|
+
id: z.string(),
|
|
48
|
+
anchor: SceneAnchorSchema,
|
|
49
|
+
layout: LayoutSchema,
|
|
50
|
+
component: SceneComponentIdSchema,
|
|
51
|
+
/** LLM-owned; replaced wholesale on re-plan. Validated against the registry. */
|
|
52
|
+
props: z.record(z.string(), z.unknown()),
|
|
53
|
+
/** User-owned; NEVER clobbered by a re-plan. Merged over props at resolve time. */
|
|
54
|
+
overrides: z.record(z.string(), z.unknown()).default({}),
|
|
55
|
+
/** Why the producer chose this — surfaced in the report for taste-debugging. */
|
|
56
|
+
rationale: z.string().optional(),
|
|
57
|
+
});
|
|
58
|
+
export type Scene = z.infer<typeof SceneSchema>;
|
|
59
|
+
|
|
60
|
+
/** A resolved, output-timed scene — what the composition actually renders. */
|
|
61
|
+
export const SceneCueSchema = z
|
|
62
|
+
.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
/**
|
|
65
|
+
* "graphic" cues come from the producer; "plain" cues are derived filler
|
|
66
|
+
* (`fillPlainCues`) — one per continuous take, so every second of the
|
|
67
|
+
* timeline is a selectable block whose framing can be edited. OPTIONAL
|
|
68
|
+
* rather than defaulted, deliberately: `render-props.json` reaches the
|
|
69
|
+
* editor as plain JSON with no schema parse, so cues written before this
|
|
70
|
+
* field existed carry no `kind` at runtime — absence means "graphic", and
|
|
71
|
+
* a defaulted (required-in-type) field would let code read `.kind` as
|
|
72
|
+
* always-present when it isn't. Always test `kind === "plain"`, never
|
|
73
|
+
* `=== "graphic"`.
|
|
74
|
+
*/
|
|
75
|
+
kind: z.enum(["graphic", "plain"]).optional(),
|
|
76
|
+
layout: LayoutSchema,
|
|
77
|
+
/**
|
|
78
|
+
* Required for graphic cues (the superRefine below enforces it), absent on
|
|
79
|
+
* plain ones. The optionality is the consumer checklist: TS strict forces
|
|
80
|
+
* every `cue.component`/`cue.props` reader to state what it does with a
|
|
81
|
+
* plain cue.
|
|
82
|
+
*/
|
|
83
|
+
component: SceneComponentIdSchema.optional(),
|
|
84
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
85
|
+
startSec: z.number().nonnegative(),
|
|
86
|
+
endSec: z.number().nonnegative(),
|
|
87
|
+
/**
|
|
88
|
+
* Overrides the layout's graphic slot for this cue only. Set when the
|
|
89
|
+
* source already has text where the layout would have drawn, so the graphic
|
|
90
|
+
* is moved into a genuinely free band instead of being skipped
|
|
91
|
+
* (FINDINGS §26). Fractions of the frame, like every other rect.
|
|
92
|
+
*/
|
|
93
|
+
graphicRect: z
|
|
94
|
+
.object({
|
|
95
|
+
x: z.number(),
|
|
96
|
+
y: z.number(),
|
|
97
|
+
w: z.number(),
|
|
98
|
+
h: z.number(),
|
|
99
|
+
})
|
|
100
|
+
.optional(),
|
|
101
|
+
/** Per-element nudges from the user's edit layer, by `data-edit-id`. */
|
|
102
|
+
elements: z.record(z.string(), z.object({
|
|
103
|
+
dx: z.number().optional(),
|
|
104
|
+
dy: z.number().optional(),
|
|
105
|
+
scale: z.number().positive().optional(),
|
|
106
|
+
})).optional(),
|
|
107
|
+
/**
|
|
108
|
+
* How the video sits in this scene's slot, when the automatic face-aware
|
|
109
|
+
* crop needs a hand (see `SceneOverrideSchema.video`). `scale` under 1 zooms
|
|
110
|
+
* OUT — more of the source, backdrop showing where it no longer covers.
|
|
111
|
+
*/
|
|
112
|
+
video: z
|
|
113
|
+
.object({
|
|
114
|
+
scale: z.number().positive().max(4).optional(),
|
|
115
|
+
dy: z.number().optional(),
|
|
116
|
+
dx: z.number().optional(),
|
|
117
|
+
/** `false` switches the automatic idle-zoom layer off for this scene. */
|
|
118
|
+
autoZoom: z.boolean().optional(),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
121
|
+
/**
|
|
122
|
+
* The pip bubble, reshaped per scene (R14 §52): mask roundness (0 = square
|
|
123
|
+
* card, 1 = the default circle) and the slot's top-left placement, frame
|
|
124
|
+
* fractions. Only consulted when the cue's resolved layout is `pip-bubble` —
|
|
125
|
+
* it is a property of the bubble, not of the video in general.
|
|
126
|
+
*/
|
|
127
|
+
pip: z
|
|
128
|
+
.object({
|
|
129
|
+
cornerRadius: z.number().min(0).max(1).optional(),
|
|
130
|
+
x: z.number().min(0).max(1).optional(),
|
|
131
|
+
y: z.number().min(0).max(1).optional(),
|
|
132
|
+
})
|
|
133
|
+
.optional(),
|
|
134
|
+
/**
|
|
135
|
+
* Vertical centre for this scene's captions, frame fraction (R15 §56) —
|
|
136
|
+
* a hand-set anchor that wins over the layout's `captionAnchor` and the
|
|
137
|
+
* automatic avoidance. Set from the editor; travels on the cue so the
|
|
138
|
+
* renderer and the preview agree.
|
|
139
|
+
*/
|
|
140
|
+
captionY: z.number().min(0).max(1).optional(),
|
|
141
|
+
/** Caption size multiplier for this scene (R16 §64) — scales the track's
|
|
142
|
+
* base font size, same convention as every other scale control. */
|
|
143
|
+
captionScale: z.number().min(0.2).max(3).optional(),
|
|
144
|
+
/** True when the user set an absolute time, detaching this cue from its words. */
|
|
145
|
+
pinned: z.boolean().optional(),
|
|
146
|
+
})
|
|
147
|
+
.superRefine((cue, ctx) => {
|
|
148
|
+
if (cue.kind !== "plain" && (cue.component === undefined || cue.props === undefined)) {
|
|
149
|
+
ctx.addIssue({
|
|
150
|
+
code: "custom",
|
|
151
|
+
message: "a graphic cue requires component and props; only kind: \"plain\" may omit them",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
export type SceneCue = z.infer<typeof SceneCueSchema>;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Where the speaker's face sits in the SOURCE frame, measured once per source
|
|
159
|
+
* (FINDINGS §13). The stage derives each layout's vertical crop bias from
|
|
160
|
+
* this instead of guessing with a constant.
|
|
161
|
+
*/
|
|
162
|
+
export const FaceCropSchema = z.object({
|
|
163
|
+
/** Vertical center of the face, 0..1 of source height. */
|
|
164
|
+
centerYFrac: z.number().min(0).max(1),
|
|
165
|
+
/** Face height as a fraction of source height (informational for now). */
|
|
166
|
+
sizeFrac: z.number().min(0).max(1).optional(),
|
|
167
|
+
/**
|
|
168
|
+
* Horizontal center, 0..1 of source width. Only matters when the source is
|
|
169
|
+
* WIDER than the slot it fills — a portrait take is cropped vertically and
|
|
170
|
+
* the speaker's horizontal position is whatever the source framed. A
|
|
171
|
+
* landscape take in a vertical slot is cropped horizontally instead, and
|
|
172
|
+
* centring it blindly can crop the speaker out of their own video.
|
|
173
|
+
*/
|
|
174
|
+
centerXFrac: z.number().min(0).max(1).optional(),
|
|
175
|
+
/**
|
|
176
|
+
* The source's width/height. Absent means "the same 9:16 the frame is",
|
|
177
|
+
* which is what every crop calculation used to assume outright — true for
|
|
178
|
+
* phone footage, wrong for a webcam recording or a screen capture.
|
|
179
|
+
*/
|
|
180
|
+
sourceAspect: z.number().positive().optional(),
|
|
181
|
+
});
|
|
182
|
+
export type FaceCrop = z.infer<typeof FaceCropSchema>;
|
|
183
|
+
|
|
184
|
+
/** Design tokens. Components read ONLY these — no hardcoded colors/fonts. */
|
|
185
|
+
export const ThemeSchema = z.object({
|
|
186
|
+
bg: z.string().default("#0B0B0E"),
|
|
187
|
+
fg: z.string().default("#FFFFFF"),
|
|
188
|
+
accent: z.string().default("#FFE14D"),
|
|
189
|
+
muted: z.string().default("#9A9AA3"),
|
|
190
|
+
/** Affirmative green — the ✓ of §66's verdict lines; the editor's own
|
|
191
|
+
* "Saved" green, so the system stays one palette. */
|
|
192
|
+
success: z.string().default("#5FBF77"),
|
|
193
|
+
cardBg: z.string().default("#15151B"),
|
|
194
|
+
cardBorder: z.string().default("#2A2A33"),
|
|
195
|
+
danger: z.string().default("#FF5C5C"),
|
|
196
|
+
radiusPx: z.number().default(24),
|
|
197
|
+
fontDisplay: z
|
|
198
|
+
.string()
|
|
199
|
+
.default("'Inter', 'Helvetica Neue', 'Arial Black', Arial, sans-serif"),
|
|
200
|
+
fontMono: z.string().default("'SF Mono', 'Cascadia Code', Consolas, monospace"),
|
|
201
|
+
});
|
|
202
|
+
export type Theme = z.infer<typeof ThemeSchema>;
|
|
203
|
+
export const defaultTheme: Theme = ThemeSchema.parse({});
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/** A single transcribed word, in SOURCE time (seconds). */
|
|
4
|
+
export const WordSchema = z.object({
|
|
5
|
+
text: z.string(),
|
|
6
|
+
start: z.number().nonnegative(),
|
|
7
|
+
end: z.number().nonnegative(),
|
|
8
|
+
conf: z.number().min(0).max(1).optional(),
|
|
9
|
+
});
|
|
10
|
+
export type Word = z.infer<typeof WordSchema>;
|
|
11
|
+
|
|
12
|
+
export const TranscriptSchema = z.object({
|
|
13
|
+
language: z.string().default("en"),
|
|
14
|
+
words: z.array(WordSchema),
|
|
15
|
+
});
|
|
16
|
+
export type Transcript = z.infer<typeof TranscriptSchema>;
|
|
17
|
+
|
|
18
|
+
export const CleanupLevelSchema = z.enum(["exact", "light", "standard", "aggressive"]);
|
|
19
|
+
export type CleanupLevel = z.infer<typeof CleanupLevelSchema>;
|
|
20
|
+
|
|
21
|
+
export const RemovalReasonSchema = z.enum(["silence", "pause", "filler", "retake", "user", "clip"]);
|
|
22
|
+
export type RemovalReason = z.infer<typeof RemovalReasonSchema>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One span of the source timeline. The cutlist is a full partition of
|
|
26
|
+
* [0, source duration]: every instant is either kept or removed, with a reason.
|
|
27
|
+
*/
|
|
28
|
+
export const SegmentSchema = z.object({
|
|
29
|
+
srcIn: z.number().nonnegative(),
|
|
30
|
+
srcOut: z.number().nonnegative(),
|
|
31
|
+
kind: z.enum(["keep", "remove"]),
|
|
32
|
+
reason: RemovalReasonSchema.optional(),
|
|
33
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
34
|
+
});
|
|
35
|
+
export type Segment = z.infer<typeof SegmentSchema>;
|
|
36
|
+
|
|
37
|
+
export const SpanSchema = z.object({
|
|
38
|
+
start: z.number(),
|
|
39
|
+
end: z.number(),
|
|
40
|
+
});
|
|
41
|
+
export type Span = z.infer<typeof SpanSchema>;
|
|
42
|
+
|
|
43
|
+
export const AnalysisSchema = z.object({
|
|
44
|
+
/** Acoustic silences (ffmpeg silencedetect), source time. */
|
|
45
|
+
silences: z.array(SpanSchema),
|
|
46
|
+
/** Inter-word transcript gaps, incl. leading/trailing dead air. */
|
|
47
|
+
gaps: z.array(SpanSchema),
|
|
48
|
+
/**
|
|
49
|
+
* Regions containing no audible speech, after transcript veto — the
|
|
50
|
+
* candidate pool every silence/pause cut is drawn from.
|
|
51
|
+
*/
|
|
52
|
+
cuttable: z.array(SpanSchema),
|
|
53
|
+
/**
|
|
54
|
+
* Sub-silence pauses from the RMS series (≥120 ms, below speech − 10 dB):
|
|
55
|
+
* too short to cut, but they are where phrases actually break. `silences`
|
|
56
|
+
* has a 0.35 s floor and `gaps` is empty on `-ml 1` output, so this is the
|
|
57
|
+
* only dense phrase signal the pipeline has (FINDINGS §18).
|
|
58
|
+
*/
|
|
59
|
+
breaths: z.array(SpanSchema).default([]),
|
|
60
|
+
/** Standalone filler interjections (um, uh, …). */
|
|
61
|
+
fillers: z.array(
|
|
62
|
+
z.object({
|
|
63
|
+
wordIndex: z.number().int(),
|
|
64
|
+
text: z.string(),
|
|
65
|
+
start: z.number(),
|
|
66
|
+
end: z.number(),
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
export type Analysis = z.infer<typeof AnalysisSchema>;
|
|
71
|
+
|
|
72
|
+
export const ProbeSchema = z.object({
|
|
73
|
+
duration: z.number().positive(),
|
|
74
|
+
width: z.number().int().positive(),
|
|
75
|
+
height: z.number().int().positive(),
|
|
76
|
+
fps: z.number().positive(),
|
|
77
|
+
hasAudio: z.boolean(),
|
|
78
|
+
});
|
|
79
|
+
export type Probe = z.infer<typeof ProbeSchema>;
|
|
80
|
+
|
|
81
|
+
export const RenderSettingsSchema = z.object({
|
|
82
|
+
width: z.number().int().positive().default(1080),
|
|
83
|
+
height: z.number().int().positive().default(1920),
|
|
84
|
+
fps: z.number().positive().default(30),
|
|
85
|
+
});
|
|
86
|
+
export type RenderSettings = z.infer<typeof RenderSettingsSchema>;
|
|
87
|
+
|
|
88
|
+
import { SceneSchema, ThemeSchema } from "./scene-schema";
|
|
89
|
+
|
|
90
|
+
/** The single source of truth for a production. Every pipeline stage is a pure function over this. */
|
|
91
|
+
export const ProductionSchema = z.object({
|
|
92
|
+
version: z.literal(1),
|
|
93
|
+
source: z.object({
|
|
94
|
+
path: z.string(),
|
|
95
|
+
probe: ProbeSchema,
|
|
96
|
+
audioPath: z.string().optional(),
|
|
97
|
+
mezzaninePath: z.string().optional(),
|
|
98
|
+
/** Measured face box (FINDINGS §13) — a property of the source; null = no face found. */
|
|
99
|
+
face: z
|
|
100
|
+
.object({
|
|
101
|
+
centerXFrac: z.number(),
|
|
102
|
+
centerYFrac: z.number(),
|
|
103
|
+
sizeFrac: z.number(),
|
|
104
|
+
framesSampled: z.number().int(),
|
|
105
|
+
framesDetected: z.number().int(),
|
|
106
|
+
})
|
|
107
|
+
.nullable()
|
|
108
|
+
.optional(),
|
|
109
|
+
}),
|
|
110
|
+
cleanup: CleanupLevelSchema,
|
|
111
|
+
/** User intent for the producer brain ("educational video about agents…"). */
|
|
112
|
+
intent: z.string().optional(),
|
|
113
|
+
/**
|
|
114
|
+
* The RAW ASR transcript. `analysis` and `cutlist` index into this array,
|
|
115
|
+
* so it must stay the untouched one — see `repairs` for the corrections
|
|
116
|
+
* applied downstream (FINDINGS §17).
|
|
117
|
+
*/
|
|
118
|
+
transcript: TranscriptSchema.optional(),
|
|
119
|
+
/**
|
|
120
|
+
* Mishearing corrections applied before captions, scene copy and grounding.
|
|
121
|
+
* Kept as a diff rather than a second transcript so the production stays
|
|
122
|
+
* reproducible: `applyRepairs(transcript, repairs.filter(r => r.applied))`
|
|
123
|
+
* reconstructs exactly what was rendered.
|
|
124
|
+
*/
|
|
125
|
+
repairs: z
|
|
126
|
+
.array(
|
|
127
|
+
z.object({
|
|
128
|
+
startWord: z.number().int(),
|
|
129
|
+
endWord: z.number().int(),
|
|
130
|
+
heard: z.string(),
|
|
131
|
+
correction: z.string(),
|
|
132
|
+
applied: z.boolean(),
|
|
133
|
+
rejected: z.string().optional(),
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
.optional(),
|
|
137
|
+
analysis: AnalysisSchema.optional(),
|
|
138
|
+
cutlist: z.array(SegmentSchema).optional(),
|
|
139
|
+
/**
|
|
140
|
+
* Present on a `--clip` run (R19 §93): the target and the resolved window.
|
|
141
|
+
* `startWord`/`endWord` are indices into the PRE-slice repaired transcript
|
|
142
|
+
* (the space selection ran in); the seconds are source time and stay
|
|
143
|
+
* meaningful against the sliced `transcript` stored above.
|
|
144
|
+
*/
|
|
145
|
+
clip: z
|
|
146
|
+
.object({
|
|
147
|
+
targetSec: z.number().positive(),
|
|
148
|
+
startWord: z.number().int().nonnegative(),
|
|
149
|
+
endWord: z.number().int().nonnegative(),
|
|
150
|
+
startSec: z.number().nonnegative(),
|
|
151
|
+
endSec: z.number().nonnegative(),
|
|
152
|
+
reason: z.string(),
|
|
153
|
+
})
|
|
154
|
+
.optional(),
|
|
155
|
+
scenes: z.array(SceneSchema).optional(),
|
|
156
|
+
/**
|
|
157
|
+
* WHO planned this production (R16 §78). `usage.json` answers "what did
|
|
158
|
+
* that cost", but it describes one run and a fully-cached re-run makes no
|
|
159
|
+
* calls — so the provider that actually chose these scenes used to vanish
|
|
160
|
+
* from the workdir. This travels with the artefact it explains.
|
|
161
|
+
*
|
|
162
|
+
* `cached: true` means this run made no LLM calls and the provider named
|
|
163
|
+
* here is the one carried forward from the run that did.
|
|
164
|
+
*/
|
|
165
|
+
producer: z
|
|
166
|
+
.object({
|
|
167
|
+
provider: z.string(),
|
|
168
|
+
/** Models seen this run, editorial first — the tiering is visible. */
|
|
169
|
+
models: z.array(z.string()).default([]),
|
|
170
|
+
cached: z.boolean().default(false),
|
|
171
|
+
at: z.string().optional(),
|
|
172
|
+
})
|
|
173
|
+
.optional(),
|
|
174
|
+
theme: ThemeSchema.optional(),
|
|
175
|
+
render: RenderSettingsSchema,
|
|
176
|
+
});
|
|
177
|
+
export type Production = z.infer<typeof ProductionSchema>;
|