返回 html-video
index.ts
根目录 / packages / content-graph / src / index.ts
1 /**
2 * @html-video/content-graph — RFC-06.
3 *
4 * Structured intermediate representation produced by the agent's first round,
5 * consumed by the second round to render HTML frame sequences.
6 *
7 * See research/2026-05-28-understand-anything-takeaways.md (#1 content-graph,
8 * #4 graph-then-sort) for the design rationale.
9 */
10
11 export type NodeKind = 'entity' | 'data' | 'text';
12
13 export interface BaseNode {
14 /** Stable id; agent picks readable strings like "intro_logo", "stat_users". */
15 id: string;
16 kind: NodeKind;
17 /**
18 * Short label for UI (graph view). Optional — falls back to id.
19 */
20 label?: string;
21 /**
22 * Optional intent hint for the frame composer:
23 * "intro" / "data-bar" / "image-pan" / "quote" / "outro" / "list" / ...
24 * Free-form; the frame-composer agent maps it to template choice.
25 */
26 frameIntent?: string;
27 /**
28 * Suggested duration in seconds for this frame. Defaults to 3s if absent.
29 */
30 durationSec?: number;
31 }
32
33 export interface EntityNode extends BaseNode {
34 kind: 'entity';
35 /**
36 * Free-form props for branding entities (logo path, brand color, etc).
37 * The frame-composer reads these to seed the HTML.
38 */
39 props: Record<string, unknown>;
40 }
41
42 export interface DataNode extends BaseNode {
43 kind: 'data';
44 /**
45 * Concrete data points to visualise: numbers, percentages, time series.
46 * Schema is permissive — any JSON the composer can render.
47 */
48 data: unknown;
49 }
50
51 export interface TextNode extends BaseNode {
52 kind: 'text';
53 /**
54 * Headline / quote / caption / paragraph copy.
55 */
56 text: string;
57 }
58
59 export type Node = EntityNode | DataNode | TextNode;
60
61 export type EdgeKind = 'sequence' | 'contrast' | 'dependency';
62
63 export interface Edge {
64 /** Source node id */
65 from: string;
66 /** Target node id */
67 to: string;
68 kind: EdgeKind;
69 /**
70 * Optional human-readable reason ("contrasts before/after", "depends on
71 * concept introduced in B"). Helps the frame-composer pick layout cues.
72 */
73 reason?: string;
74 }
75
76 export interface ContentGraph {
77 /** Schema version. v1 = this RFC-06 draft. */
78 schemaVersion: 1;
79 /**
80 * High-level intent classification. Steers the frame-composer:
81 * - "single-frame": short brand/title card; collapse to one frame.
82 * - "explainer": teach a concept; honour dependency edges.
83 * - "data-viz": walk through numbers; sequence edges drive order.
84 * - "promo": pacy social-cut style.
85 * - "comparison": before/after; contrast edges drive layout.
86 */
87 intent:
88 | 'single-frame'
89 | 'explainer'
90 | 'data-viz'
91 | 'promo'
92 | 'comparison'
93 | 'other';
94 /**
95 * One-line synopsis the agent writes for itself / the user. Shown in
96 * studio's graph view as the "what is this video about?" header.
97 */
98 synopsis?: string;
99 nodes: Node[];
100 edges: Edge[];
101 }
102
103 // ---------------------------------------------------------------------------
104 // Validation
105 // ---------------------------------------------------------------------------
106
107 export interface GraphValidationError {
108 code:
109 | 'duplicate-node-id'
110 | 'edge-from-unknown-node'
111 | 'edge-to-unknown-node'
112 | 'self-edge'
113 | 'cycle'
114 | 'empty-graph'
115 | 'invalid-kind';
116 message: string;
117 /** Offending node or edge for UI highlighting. */
118 ref?: string;
119 }
120
121 export interface GraphValidationResult {
122 ok: boolean;
123 errors: GraphValidationError[];
124 warnings: GraphValidationError[];
125 }
126
127 /**
128 * Validate a ContentGraph. Stops at the first cycle (reports it) but collects
129 * all other errors so the agent gets one round-trip of feedback.
130 */
131 export function validate(graph: ContentGraph): GraphValidationResult {
132 const errors: GraphValidationError[] = [];
133 const warnings: GraphValidationError[] = [];
134
135 if (!graph.nodes || graph.nodes.length === 0) {
136 errors.push({ code: 'empty-graph', message: 'Graph has no nodes' });
137 return { ok: false, errors, warnings };
138 }
139
140 const ids = new Set<string>();
141 for (const n of graph.nodes) {
142 if (ids.has(n.id)) {
143 errors.push({
144 code: 'duplicate-node-id',
145 message: `Duplicate node id "${n.id}"`,
146 ref: n.id,
147 });
148 }
149 ids.add(n.id);
150 const kind = (n as { kind: string }).kind;
151 if (kind !== 'entity' && kind !== 'data' && kind !== 'text') {
152 errors.push({
153 code: 'invalid-kind',
154 message: `Node "${(n as { id: string }).id}" has unknown kind "${kind}"`,
155 ref: (n as { id: string }).id,
156 });
157 }
158 }
159
160 for (const e of graph.edges) {
161 if (e.from === e.to) {
162 errors.push({
163 code: 'self-edge',
164 message: `Edge ${e.from} → ${e.to} is a self-edge`,
165 ref: `${e.from}->${e.to}`,
166 });
167 }
168 if (!ids.has(e.from)) {
169 errors.push({
170 code: 'edge-from-unknown-node',
171 message: `Edge from unknown node "${e.from}"`,
172 ref: `${e.from}->${e.to}`,
173 });
174 }
175 if (!ids.has(e.to)) {
176 errors.push({
177 code: 'edge-to-unknown-node',
178 message: `Edge to unknown node "${e.to}"`,
179 ref: `${e.from}->${e.to}`,
180 });
181 }
182 }
183
184 // Cycle detection on dependency edges (the only kind that constrains order).
185 const cycleNode = findDependencyCycle(graph);
186 if (cycleNode) {
187 errors.push({
188 code: 'cycle',
189 message: `Dependency cycle detected involving node "${cycleNode}"`,
190 ref: cycleNode,
191 });
192 }
193
194 return { ok: errors.length === 0, errors, warnings };
195 }
196
197 // ---------------------------------------------------------------------------
198 // Topo sort
199 // ---------------------------------------------------------------------------
200
201 /**
202 * Linearise the graph into a frame play order.
203 *
204 * Algorithm:
205 * 1. Build dependency adjacency (only "dependency" edges constrain order).
206 * 2. Kahn topological sort; ties broken by sequence-edge order, else by
207 * original node array order.
208 *
209 * Returns node ids in playback order. Throws on cycle (callers should validate
210 * first; this is a defensive throw).
211 */
212 export function topoSort(graph: ContentGraph): string[] {
213 const indeg = new Map<string, number>();
214 const deps = new Map<string, string[]>(); // from -> to (unblocks)
215 const nodeOrder = new Map<string, number>();
216 graph.nodes.forEach((n, i) => {
217 indeg.set(n.id, 0);
218 deps.set(n.id, []);
219 nodeOrder.set(n.id, i);
220 });
221 for (const e of graph.edges) {
222 if (e.kind !== 'dependency') continue;
223 if (!indeg.has(e.from) || !indeg.has(e.to)) continue;
224 deps.get(e.from)!.push(e.to);
225 indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
226 }
227
228 // Sequence edges as a soft preference: if A->B (sequence) and both indeg=0,
229 // prefer A before B.
230 const seqAfter = new Map<string, Set<string>>(); // node -> nodes that should come after
231 for (const e of graph.edges) {
232 if (e.kind !== 'sequence') continue;
233 if (!indeg.has(e.from) || !indeg.has(e.to)) continue;
234 if (!seqAfter.has(e.from)) seqAfter.set(e.from, new Set());
235 seqAfter.get(e.from)!.add(e.to);
236 }
237
238 const ready: string[] = [];
239 for (const [id, d] of indeg) if (d === 0) ready.push(id);
240 // Stable sort by original node order so output is deterministic
241 ready.sort((a, b) => (nodeOrder.get(a) ?? 0) - (nodeOrder.get(b) ?? 0));
242
243 const out: string[] = [];
244 while (ready.length > 0) {
245 // Pick the ready node that:
246 // 1. is NOT a "sequence successor" of any other ready node, and
247 // 2. earliest in original order among the survivors.
248 let pickIdx = 0;
249 for (let i = 0; i < ready.length; i++) {
250 const cand = ready[i]!;
251 const blockedBySequence = ready.some(
252 (other) => other !== cand && seqAfter.get(other)?.has(cand),
253 );
254 if (!blockedBySequence) {
255 pickIdx = i;
256 break;
257 }
258 }
259 const next = ready.splice(pickIdx, 1)[0]!;
260 out.push(next);
261 for (const succ of deps.get(next) ?? []) {
262 indeg.set(succ, (indeg.get(succ) ?? 1) - 1);
263 if (indeg.get(succ) === 0) {
264 // Insert maintaining original-order stability
265 const ord = nodeOrder.get(succ) ?? 0;
266 let insertAt = ready.length;
267 for (let i = 0; i < ready.length; i++) {
268 if ((nodeOrder.get(ready[i]!) ?? 0) > ord) {
269 insertAt = i;
270 break;
271 }
272 }
273 ready.splice(insertAt, 0, succ);
274 }
275 }
276 }
277
278 if (out.length !== graph.nodes.length) {
279 throw new Error(
280 `topoSort: cycle detected (sorted ${out.length} of ${graph.nodes.length} nodes)`,
281 );
282 }
283 return out;
284 }
285
286 // ---------------------------------------------------------------------------
287 // Helpers
288 // ---------------------------------------------------------------------------
289
290 function findDependencyCycle(graph: ContentGraph): string | null {
291 const adj = new Map<string, string[]>();
292 for (const n of graph.nodes) adj.set(n.id, []);
293 for (const e of graph.edges) {
294 if (e.kind !== 'dependency') continue;
295 if (!adj.has(e.from) || !adj.has(e.to)) continue;
296 adj.get(e.from)!.push(e.to);
297 }
298 const WHITE = 0,
299 GRAY = 1,
300 BLACK = 2;
301 const color = new Map<string, number>();
302 for (const id of adj.keys()) color.set(id, WHITE);
303 const stack: { id: string; iter: Iterator<string> }[] = [];
304 for (const start of adj.keys()) {
305 if (color.get(start) !== WHITE) continue;
306 color.set(start, GRAY);
307 stack.push({ id: start, iter: adj.get(start)![Symbol.iterator]() });
308 while (stack.length > 0) {
309 const top = stack[stack.length - 1]!;
310 const next = top.iter.next();
311 if (next.done) {
312 color.set(top.id, BLACK);
313 stack.pop();
314 } else {
315 const c = color.get(next.value);
316 if (c === GRAY) return next.value;
317 if (c === WHITE) {
318 color.set(next.value, GRAY);
319 stack.push({ id: next.value, iter: adj.get(next.value)![Symbol.iterator]() });
320 }
321 }
322 }
323 }
324 return null;
325 }
326
327 /**
328 * Look up a node by id. Returns undefined if missing — callers handle.
329 */
330 export function getNode(graph: ContentGraph, id: string): Node | undefined {
331 return graph.nodes.find((n) => n.id === id);
332 }
333
334 /**
335 * Default per-frame duration when a node doesn't set one.
336 */
337 export const DEFAULT_FRAME_DURATION_SEC = 3;
338
339 /**
340 * Compute total video duration by summing per-frame durations along the
341 * topo-sorted play order.
342 */
343 export function totalDurationSec(graph: ContentGraph): number {
344 const order = topoSort(graph);
345 let total = 0;
346 for (const id of order) {
347 const n = getNode(graph, id);
348 total += n?.durationSec ?? DEFAULT_FRAME_DURATION_SEC;
349 }
350 return total;
351 }
352
352 lines TYPESCRIPT