| 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 |