@bendyline/squisq 0.1.1

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.
Files changed (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,565 @@
1
+ /**
2
+ * Doc Schema
3
+ *
4
+ * Defines the JSON format for AI-scriptable visual docs that accompany
5
+ * audio narration. A Doc describes a sequence of animated blocks
6
+ * with text overlays, images, and transitions synchronized to audio.
7
+ *
8
+ * The format is designed to be:
9
+ * 1. AI-generatable - Claude can create scripts from article content
10
+ * 2. Browser-renderable - SVG + CSS animations (ES2015 compatible)
11
+ * 3. Video-exportable - Frame-by-frame capture via Playwright
12
+ *
13
+ * Timing: Blocks are timed to match narration audio segments. Each article
14
+ * has 3-6 MP3 files (intro + sections), and blocks are grouped by segment.
15
+ */
16
+
17
+ // ============================================
18
+ // Core Types
19
+ // ============================================
20
+
21
+ /**
22
+ * Configuration for the Start/resting block shown before playback begins.
23
+ */
24
+ export interface StartBlockConfig {
25
+ /** Hero image path (relative to article media) */
26
+ heroSrc: string;
27
+ /** Alt text for the hero image */
28
+ heroAlt?: string;
29
+ /** Title to display over the hero */
30
+ title: string;
31
+ /** Optional subtitle */
32
+ subtitle?: string;
33
+ /** Ambient motion for the hero image */
34
+ ambientMotion?: 'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight';
35
+ /** Photo credit / artist name for the hero image */
36
+ heroCredit?: string;
37
+ /** License identifier for the hero image */
38
+ heroLicense?: string;
39
+ }
40
+
41
+ /**
42
+ * A complete visual doc for an article.
43
+ */
44
+ export interface Doc {
45
+ /** Article ID this doc belongs to */
46
+ articleId: string;
47
+
48
+ /** Total duration in seconds (sum of all audio segments) */
49
+ duration: number;
50
+
51
+ /** Ordered list of blocks */
52
+ blocks: Block[];
53
+
54
+ /** Audio track configuration */
55
+ audio: AudioTrack;
56
+
57
+ /** Caption track for closed captions (generated by postprocess-doc) */
58
+ captions?: CaptionTrack;
59
+
60
+ /**
61
+ * Start/resting block shown before playback begins.
62
+ * Displays hero image with title - acts as a "poster" for the doc.
63
+ * The player shows this until the user starts playback.
64
+ */
65
+ startBlock?: StartBlockConfig;
66
+
67
+ /**
68
+ * Persistent layers rendered on every block (behind and/or on top of content).
69
+ * Used for consistent branding overlays, background gradients, etc.
70
+ */
71
+ persistentLayers?: import('./BlockTemplates.js').PersistentLayerConfig;
72
+
73
+ /**
74
+ * Optional theme identifier. Resolved at render time via `resolveTheme()`.
75
+ * When omitted, the renderer uses the default theme ('documentary').
76
+ */
77
+ themeId?: string;
78
+
79
+ /** Optional metadata */
80
+ meta?: {
81
+ generatedAt?: string;
82
+ generatedBy?: string;
83
+ version?: number;
84
+ };
85
+
86
+ /**
87
+ * YAML frontmatter from the source markdown.
88
+ * Carries rendering hints like `document-render-as` and custom metadata.
89
+ */
90
+ frontmatter?: Record<string, unknown>;
91
+ }
92
+
93
+ /**
94
+ * A single block in the doc.
95
+ *
96
+ * Blocks can be flat (audio-synced timeline) or nested (markdown-driven hierarchy).
97
+ * When derived from markdown, a block corresponds to a heading section:
98
+ * - `sourceHeading` references the original MarkdownHeading node
99
+ * - `contents` holds the body markdown between this heading and the next
100
+ * - `children` holds sub-heading blocks (e.g., H2s under an H1)
101
+ */
102
+ export interface Block {
103
+ /** Unique identifier for this block */
104
+ id: string;
105
+
106
+ /** When this block appears (seconds from start) */
107
+ startTime: number;
108
+
109
+ /** How long this block is visible (seconds) */
110
+ duration: number;
111
+
112
+ /** Which audio segment this block belongs to (0-indexed) */
113
+ audioSegment: number;
114
+
115
+ /**
116
+ * Pre-computed visual layers, rendered back-to-front.
117
+ *
118
+ * Optional — template-derived blocks typically omit this and compute
119
+ * layers on demand via `getLayers()` from `@bendyline/squisq/doc`.
120
+ * Raw blocks (e.g., hand-crafted by AI) may provide layers directly.
121
+ */
122
+ layers?: Layer[];
123
+
124
+ /** Entry transition from previous block */
125
+ transition?: Transition;
126
+
127
+ /** Template name that generated this block (for debugging) */
128
+ template?: string;
129
+
130
+ // ── Markdown-driven hierarchy (optional) ──
131
+
132
+ /**
133
+ * Nested sub-blocks (heading hierarchy).
134
+ * When a doc is derived from markdown, sub-headings within this
135
+ * heading's section become child blocks. For example, H2 blocks
136
+ * under an H1 block appear here.
137
+ */
138
+ children?: Block[];
139
+
140
+ /**
141
+ * Markdown body content for this block's section.
142
+ * Contains the block-level markdown nodes (paragraphs, lists, code,
143
+ * blockquotes, etc.) that appear between this heading and the next
144
+ * heading or sub-heading. Empty for pure structural blocks.
145
+ */
146
+ contents?: import('../markdown/types.js').MarkdownBlockNode[];
147
+
148
+ /**
149
+ * Reference to the MarkdownHeading node that created this block.
150
+ * Present when the block was derived from markdown via markdownToDoc().
151
+ * Enables round-tripping back to markdown and provides heading depth.
152
+ * Absent for the preamble block (content before the first heading).
153
+ */
154
+ sourceHeading?: import('../markdown/types.js').MarkdownHeading;
155
+
156
+ /**
157
+ * Template overrides extracted from markdown directives.
158
+ * For example, `### Data Section {template=chart colorScheme=blue}`
159
+ * would produce `{ template: 'chart', colorScheme: 'blue' }`.
160
+ * Allows per-block customization of template selection and parameters.
161
+ */
162
+ templateOverrides?: Record<string, string>;
163
+ }
164
+
165
+ // ============================================
166
+ // Layer Types
167
+ // ============================================
168
+
169
+ /**
170
+ * A visual element within a block.
171
+ * Layers are composited back-to-front (first layer is background).
172
+ */
173
+ export type Layer = ImageLayer | TextLayer | ShapeLayer | MapLayer | VideoLayer;
174
+
175
+ interface BaseLayer {
176
+ /** Unique identifier for this layer */
177
+ id: string;
178
+
179
+ /** Position and size */
180
+ position: Position;
181
+
182
+ /** Animation to apply */
183
+ animation?: Animation;
184
+ }
185
+
186
+ /**
187
+ * Image layer - displays an image with optional Ken Burns effect.
188
+ */
189
+ export interface ImageLayer extends BaseLayer {
190
+ type: 'image';
191
+ content: {
192
+ /** Path to image file (relative to article media dir) */
193
+ src: string;
194
+ /** Alt text for accessibility */
195
+ alt: string;
196
+ /** How to fit image in bounds */
197
+ fit?: 'cover' | 'contain' | 'fill';
198
+ /** Photo credit / artist name */
199
+ credit?: string;
200
+ /** License identifier (e.g., 'CC BY-SA 4.0') */
201
+ license?: string;
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Text layer - displays text with styling.
207
+ */
208
+ export interface TextLayer extends BaseLayer {
209
+ type: 'text';
210
+ content: {
211
+ /** Text to display (supports \n for line breaks) */
212
+ text: string;
213
+ /** Text styling */
214
+ style: TextStyle;
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Shape layer - simple geometric shapes for visual accents.
220
+ */
221
+ export interface ShapeLayer extends BaseLayer {
222
+ type: 'shape';
223
+ content: {
224
+ /** Shape type */
225
+ shape: 'rect' | 'circle' | 'line';
226
+ /** Fill color (CSS color or 'none') */
227
+ fill?: string;
228
+ /** Stroke color */
229
+ stroke?: string;
230
+ /** Stroke width in pixels */
231
+ strokeWidth?: number;
232
+ /** Corner radius for rect */
233
+ borderRadius?: number;
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Map layer - displays a geographic map with optional markers.
239
+ *
240
+ * Maps are rendered as composite tile images from free tile providers.
241
+ * Supported styles use open-source/free tiles requiring attribution.
242
+ */
243
+ export interface MapLayer extends BaseLayer {
244
+ type: 'map';
245
+ content: {
246
+ /** Center coordinates for the map */
247
+ center: {
248
+ lat: number;
249
+ lng: number;
250
+ };
251
+ /** Zoom level (0-18, typically 8-14 for doc blocks) */
252
+ zoom: number;
253
+ /** Map tile style */
254
+ style: MapTileStyle;
255
+ /** Optional markers to display on the map */
256
+ markers?: MapMarker[];
257
+ /** Pre-rendered static image path (for video export reliability) */
258
+ staticSrc?: string;
259
+ /** Show attribution text (default: true) */
260
+ showAttribution?: boolean;
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Video layer - displays a short video clip, always muted (narration provides audio).
266
+ *
267
+ * Rendered via an HTML5 <video> element inside <foreignObject>, similar to how
268
+ * ImageLayer uses <img> for Ken Burns effects. clipStart/clipEnd define the window
269
+ * within the source video to play. During Playwright frame capture (seekTo mode),
270
+ * the video is seeked programmatically rather than playing in real time.
271
+ */
272
+ export interface VideoLayer extends BaseLayer {
273
+ type: 'video';
274
+ content: {
275
+ /** Path to video file (relative to article media dir) */
276
+ src: string;
277
+ /** Path to poster frame (shown before video loads / during seek) */
278
+ posterSrc?: string;
279
+ /** Alt text for accessibility */
280
+ alt: string;
281
+ /** How to fit video in bounds */
282
+ fit?: 'cover' | 'contain' | 'fill';
283
+ /** Start time within the source video (seconds) */
284
+ clipStart: number;
285
+ /** End time within the source video (seconds) */
286
+ clipEnd: number;
287
+ /** Total source video duration (for validation) */
288
+ sourceDuration?: number;
289
+ /** Video credit / artist name */
290
+ credit?: string;
291
+ /** License identifier (e.g., 'CC BY-SA 4.0') */
292
+ license?: string;
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Available map tile styles from free/open-source providers.
298
+ */
299
+ export type MapTileStyle =
300
+ | 'terrain' // OpenTopoMap - topographic/terrain (default)
301
+ | 'satellite' // ESRI World Imagery
302
+ | 'road' // OpenStreetMap standard
303
+ | 'toner' // Stadia Toner (high contrast B&W)
304
+ | 'watercolor'; // Stadia Watercolor (artistic)
305
+
306
+ /**
307
+ * A marker to display on the map.
308
+ */
309
+ export interface MapMarker {
310
+ /** Marker latitude */
311
+ lat: number;
312
+ /** Marker longitude */
313
+ lng: number;
314
+ /** Optional label text */
315
+ label?: string;
316
+ /** Marker color (CSS color, default: #ef4444) */
317
+ color?: string;
318
+ /** Marker icon type */
319
+ icon?: 'pin' | 'circle' | 'star';
320
+ }
321
+
322
+ // ============================================
323
+ // Position & Styling
324
+ // ============================================
325
+
326
+ /**
327
+ * Position and dimensions for a layer.
328
+ * Values can be pixels (number) or percentages (string like "50%").
329
+ */
330
+ export interface Position {
331
+ /** X position (pixels or percentage) */
332
+ x: number | string;
333
+ /** Y position (pixels or percentage) */
334
+ y: number | string;
335
+ /** Width (pixels or percentage) */
336
+ width?: number | string;
337
+ /** Height (pixels or percentage) */
338
+ height?: number | string;
339
+ /** Anchor point for positioning */
340
+ anchor?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
341
+ }
342
+
343
+ /**
344
+ * Text styling options.
345
+ */
346
+ export interface TextStyle {
347
+ /** Font size in pixels */
348
+ fontSize: number;
349
+ /** Font family (defaults to system sans-serif) */
350
+ fontFamily?: string;
351
+ /** Font weight */
352
+ fontWeight?: 'normal' | 'bold';
353
+ /** Text color (CSS color) */
354
+ color: string;
355
+ /** Text alignment */
356
+ textAlign?: 'left' | 'center' | 'right';
357
+ /** Line height multiplier */
358
+ lineHeight?: number;
359
+ /** Add drop shadow for readability over images */
360
+ shadow?: boolean;
361
+ /** Background color for text box */
362
+ background?: string;
363
+ /** Padding around text (pixels) */
364
+ padding?: number;
365
+ /** Maximum number of lines before truncation (adds "..." to last line) */
366
+ maxLines?: number;
367
+ }
368
+
369
+ // ============================================
370
+ // Animation & Transitions
371
+ // ============================================
372
+
373
+ /**
374
+ * Animation to apply to a layer.
375
+ */
376
+ export interface Animation {
377
+ /** Animation type */
378
+ type: AnimationType;
379
+ /** Duration in seconds (defaults to layer's block duration) */
380
+ duration?: number;
381
+ /** Delay before animation starts (seconds) */
382
+ delay?: number;
383
+ /** CSS easing function */
384
+ easing?: string;
385
+ /** For Ken Burns: zoom direction */
386
+ direction?: 'in' | 'out';
387
+ /** For pan animations: pan direction */
388
+ panDirection?: 'left' | 'right' | 'up' | 'down';
389
+ }
390
+
391
+ /**
392
+ * Available animation types.
393
+ */
394
+ export type AnimationType =
395
+ | 'none'
396
+ | 'fadeIn'
397
+ | 'fadeOut'
398
+ | 'slowZoom' // Slow zoom with optional pan
399
+ | 'zoomIn'
400
+ | 'zoomOut'
401
+ | 'panLeft'
402
+ | 'panRight'
403
+ | 'typewriter'; // Text appears letter by letter
404
+
405
+ /**
406
+ * Transition between blocks.
407
+ */
408
+ export interface Transition {
409
+ /** Transition type */
410
+ type: TransitionType;
411
+ /** Duration in seconds */
412
+ duration: number;
413
+ }
414
+
415
+ export type TransitionType =
416
+ | 'cut' // Instant switch
417
+ | 'fade' // Cross-fade
418
+ | 'dissolve' // Soft dissolve
419
+ | 'slideLeft' // New block enters from right
420
+ | 'slideRight'; // New block enters from left
421
+
422
+ // ============================================
423
+ // Audio Configuration
424
+ // ============================================
425
+
426
+ /**
427
+ * Audio track configuration.
428
+ * Articles have multiple MP3 segments (intro + sections).
429
+ */
430
+ export interface AudioTrack {
431
+ /** Audio segments in playback order */
432
+ segments: AudioSegment[];
433
+ }
434
+
435
+ // ============================================
436
+ // Captions
437
+ // ============================================
438
+
439
+ /**
440
+ * A single caption phrase to display during playback.
441
+ */
442
+ export interface CaptionPhrase {
443
+ /** The text to display */
444
+ text: string;
445
+ /** Start time in seconds (relative to doc start) */
446
+ startTime: number;
447
+ /** End time in seconds */
448
+ endTime: number;
449
+ /** Which audio segment this caption belongs to (0-indexed) */
450
+ audioSegment: number;
451
+ }
452
+
453
+ /**
454
+ * Caption track for a doc.
455
+ */
456
+ export interface CaptionTrack {
457
+ /** Caption phrases in chronological order */
458
+ phrases: CaptionPhrase[];
459
+ /** When the captions were generated */
460
+ generatedAt: string;
461
+ /** Algorithm version for regeneration detection */
462
+ version: number;
463
+ }
464
+
465
+ /**
466
+ * A single audio segment (one MP3 file).
467
+ */
468
+ export interface AudioSegment {
469
+ /** Path to MP3 file (relative to article media dir) */
470
+ src: string;
471
+ /** Segment name (e.g., "intro", "history") */
472
+ name: string;
473
+ /** Duration in seconds */
474
+ duration: number;
475
+ /** Start time in overall timeline (calculated) */
476
+ startTime: number;
477
+ }
478
+
479
+ // ============================================
480
+ // Helper Functions
481
+ // ============================================
482
+
483
+ /**
484
+ * Calculate total duration from audio segments.
485
+ */
486
+ export function calculateDuration(audio: AudioTrack): number {
487
+ return audio.segments.reduce((sum, seg) => sum + seg.duration, 0);
488
+ }
489
+
490
+ /**
491
+ * Find which audio segment is playing at a given time.
492
+ */
493
+ export function getSegmentAtTime(audio: AudioTrack, time: number): number {
494
+ let elapsed = 0;
495
+ for (let i = 0; i < audio.segments.length; i++) {
496
+ elapsed += audio.segments[i].duration;
497
+ if (time < elapsed) return i;
498
+ }
499
+ return audio.segments.length - 1;
500
+ }
501
+
502
+ /**
503
+ * Find which block should be visible at a given time.
504
+ */
505
+ export function getBlockAtTime(blocks: Block[], time: number): Block | null {
506
+ for (let i = blocks.length - 1; i >= 0; i--) {
507
+ const block = blocks[i];
508
+ if (time >= block.startTime && time < block.startTime + block.duration) {
509
+ return block;
510
+ }
511
+ }
512
+ return blocks[0] || null;
513
+ }
514
+
515
+ /**
516
+ * Find the caption phrase that should be displayed at a given time.
517
+ */
518
+ export function getCaptionAtTime(
519
+ captions: CaptionTrack | undefined,
520
+ time: number,
521
+ ): CaptionPhrase | null {
522
+ if (!captions || !captions.phrases.length) return null;
523
+
524
+ for (const phrase of captions.phrases) {
525
+ if (time >= phrase.startTime && time < phrase.endTime) {
526
+ return phrase;
527
+ }
528
+ }
529
+ return null;
530
+ }
531
+
532
+ /**
533
+ * Validate a Doc for completeness.
534
+ */
535
+ export function validateDoc(script: Doc): string[] {
536
+ const errors: string[] = [];
537
+
538
+ if (!script.articleId) {
539
+ errors.push('Missing articleId');
540
+ }
541
+
542
+ if (!script.blocks || script.blocks.length === 0) {
543
+ errors.push('No blocks defined');
544
+ }
545
+
546
+ if (!script.audio || !script.audio.segments || script.audio.segments.length === 0) {
547
+ errors.push('No audio segments defined');
548
+ }
549
+
550
+ // Check for gaps in block coverage
551
+ const totalDuration = script.duration;
552
+ let covered = 0;
553
+ for (const block of script.blocks) {
554
+ if (block.startTime > covered + 0.1) {
555
+ errors.push(`Gap in blocks: ${covered}s to ${block.startTime}s`);
556
+ }
557
+ covered = Math.max(covered, block.startTime + block.duration);
558
+ }
559
+
560
+ if (covered < totalDuration - 0.1) {
561
+ errors.push(`Blocks end at ${covered}s but audio is ${totalDuration}s`);
562
+ }
563
+
564
+ return errors;
565
+ }