@gannochenko/staticstripes 0.0.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 (86) hide show
  1. package/.prettierrc +8 -0
  2. package/Makefile +69 -0
  3. package/dist/asset-manager.d.ts +16 -0
  4. package/dist/asset-manager.d.ts.map +1 -0
  5. package/dist/asset-manager.js +50 -0
  6. package/dist/asset-manager.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +257 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/container-renderer.d.ts +21 -0
  12. package/dist/container-renderer.d.ts.map +1 -0
  13. package/dist/container-renderer.js +149 -0
  14. package/dist/container-renderer.js.map +1 -0
  15. package/dist/expression-parser.d.ts +63 -0
  16. package/dist/expression-parser.d.ts.map +1 -0
  17. package/dist/expression-parser.js +145 -0
  18. package/dist/expression-parser.js.map +1 -0
  19. package/dist/ffmpeg.d.ts +375 -0
  20. package/dist/ffmpeg.d.ts.map +1 -0
  21. package/dist/ffmpeg.js +997 -0
  22. package/dist/ffmpeg.js.map +1 -0
  23. package/dist/ffprobe.d.ts +2 -0
  24. package/dist/ffprobe.d.ts.map +1 -0
  25. package/dist/ffprobe.js +31 -0
  26. package/dist/ffprobe.js.map +1 -0
  27. package/dist/html-parser.d.ts +56 -0
  28. package/dist/html-parser.d.ts.map +1 -0
  29. package/dist/html-parser.js +208 -0
  30. package/dist/html-parser.js.map +1 -0
  31. package/dist/html-project-parser.d.ts +169 -0
  32. package/dist/html-project-parser.d.ts.map +1 -0
  33. package/dist/html-project-parser.js +954 -0
  34. package/dist/html-project-parser.js.map +1 -0
  35. package/dist/index.d.ts +6 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +18 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/label-generator.d.ts +35 -0
  40. package/dist/label-generator.d.ts.map +1 -0
  41. package/dist/label-generator.js +69 -0
  42. package/dist/label-generator.js.map +1 -0
  43. package/dist/project.d.ts +29 -0
  44. package/dist/project.d.ts.map +1 -0
  45. package/dist/project.js +137 -0
  46. package/dist/project.js.map +1 -0
  47. package/dist/sample-sequences.d.ts +5 -0
  48. package/dist/sample-sequences.d.ts.map +1 -0
  49. package/dist/sample-sequences.js +199 -0
  50. package/dist/sample-sequences.js.map +1 -0
  51. package/dist/sample-streams.d.ts +2 -0
  52. package/dist/sample-streams.d.ts.map +1 -0
  53. package/dist/sample-streams.js +109 -0
  54. package/dist/sample-streams.js.map +1 -0
  55. package/dist/sequence.d.ts +21 -0
  56. package/dist/sequence.d.ts.map +1 -0
  57. package/dist/sequence.js +269 -0
  58. package/dist/sequence.js.map +1 -0
  59. package/dist/stream.d.ts +135 -0
  60. package/dist/stream.d.ts.map +1 -0
  61. package/dist/stream.js +779 -0
  62. package/dist/stream.js.map +1 -0
  63. package/dist/type.d.ts +73 -0
  64. package/dist/type.d.ts.map +1 -0
  65. package/dist/type.js +3 -0
  66. package/dist/type.js.map +1 -0
  67. package/eslint.config.js +44 -0
  68. package/package.json +50 -0
  69. package/src/asset-manager.ts +55 -0
  70. package/src/cli.ts +306 -0
  71. package/src/container-renderer.ts +190 -0
  72. package/src/expression-parser.test.ts +459 -0
  73. package/src/expression-parser.ts +199 -0
  74. package/src/ffmpeg.ts +1403 -0
  75. package/src/ffprobe.ts +29 -0
  76. package/src/html-parser.ts +221 -0
  77. package/src/html-project-parser.ts +1195 -0
  78. package/src/index.ts +9 -0
  79. package/src/label-generator.ts +74 -0
  80. package/src/project.ts +180 -0
  81. package/src/sample-sequences.ts +225 -0
  82. package/src/sample-streams.ts +142 -0
  83. package/src/sequence.ts +330 -0
  84. package/src/stream.ts +1012 -0
  85. package/src/type.ts +81 -0
  86. package/tsconfig.json +24 -0
@@ -0,0 +1,1195 @@
1
+ import {
2
+ ParsedHtml,
3
+ Asset,
4
+ Output,
5
+ Element,
6
+ ASTNode,
7
+ SequenceDefinition,
8
+ Fragment,
9
+ Container,
10
+ } from './type';
11
+ import { execFile } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { resolve, dirname } from 'path';
14
+ import { Project } from './project';
15
+ import {
16
+ parseValueLazy,
17
+ CompiledExpression,
18
+ } from './expression-parser';
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ export class HTMLProjectParser {
23
+ private projectDir: string;
24
+
25
+ constructor(
26
+ private html: ParsedHtml,
27
+ private projectPath: string,
28
+ ) {
29
+ this.projectDir = dirname(projectPath);
30
+ }
31
+
32
+ public async parse(): Promise<Project> {
33
+ const assets = await this.processAssets();
34
+ const outputs = this.processOutputs();
35
+ const sequences = this.processSequences(assets);
36
+ const cssText = this.html.cssText;
37
+
38
+ return new Project(sequences, assets, outputs, cssText, this.projectPath);
39
+ }
40
+
41
+ /**
42
+ * Processes asset elements from the parsed HTML and builds an assets map
43
+ */
44
+ private async processAssets(): Promise<Asset[]> {
45
+ const result: Asset[] = [];
46
+
47
+ // Find all elements with class "asset" or data-asset attribute
48
+ const assetElements = this.findAssetElements();
49
+
50
+ for (const element of assetElements) {
51
+ const asset = await this.extractAssetFromElement(element);
52
+ if (asset) {
53
+ result.push(asset);
54
+ }
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Finds all asset elements in the HTML
62
+ */
63
+ private findAssetElements(): Element[] {
64
+ const results: Element[] = [];
65
+
66
+ const traverse = (node: ASTNode) => {
67
+ if ('tagName' in node) {
68
+ const element = node as Element;
69
+
70
+ // Check if element is an <asset> tag
71
+ if (element.tagName === 'asset') {
72
+ results.push(element);
73
+ }
74
+ }
75
+
76
+ if ('childNodes' in node && node.childNodes) {
77
+ for (const child of node.childNodes) {
78
+ traverse(child);
79
+ }
80
+ }
81
+ };
82
+
83
+ traverse(this.html.ast);
84
+ return results;
85
+ }
86
+
87
+ /**
88
+ * Extracts asset information from an element
89
+ */
90
+ private async extractAssetFromElement(
91
+ element: Element,
92
+ ): Promise<Asset | null> {
93
+ const attrs = new Map(element.attrs.map((attr) => [attr.name, attr.value]));
94
+
95
+ // Extract name (required)
96
+ const name = attrs.get('data-name') || attrs.get('id');
97
+ if (!name) {
98
+ console.warn('Asset element missing data-name or id attribute');
99
+ return null;
100
+ }
101
+
102
+ // Extract path (required)
103
+ const relativePath = attrs.get('data-path') || attrs.get('src');
104
+ if (!relativePath) {
105
+ console.warn(`Asset "${name}" missing data-path or src attribute`);
106
+ return null;
107
+ }
108
+
109
+ // Resolve to absolute path
110
+ const absolutePath = resolve(this.projectDir, relativePath);
111
+
112
+ // Extract type (required)
113
+ let type: 'video' | 'image' | 'audio';
114
+ const explicitType = attrs.get('data-type');
115
+ if (
116
+ explicitType === 'video' ||
117
+ explicitType === 'image' ||
118
+ explicitType === 'audio'
119
+ ) {
120
+ type = explicitType;
121
+ } else {
122
+ // Infer from tag name or file extension
123
+ type = this.inferAssetType(element.tagName, relativePath);
124
+ }
125
+
126
+ // Get duration using ffprobe (in ms) - only for audio/video
127
+ const duration = await this.getAssetDuration(absolutePath, type);
128
+
129
+ // Get dimensions using ffprobe - for video and image
130
+ const { width, height } = await this.getAssetDimensions(absolutePath, type);
131
+
132
+ // Get rotation using ffprobe - for video and image
133
+ const rotation = await this.getAssetRotation(absolutePath, type);
134
+
135
+ // Check if asset has video stream
136
+ const hasVideo = await this.getHasVideo(absolutePath, type);
137
+
138
+ // Check if asset has audio stream
139
+ const hasAudio = await this.getHasAudio(absolutePath, type);
140
+
141
+ // Extract author (optional)
142
+ const author = attrs.get('data-author');
143
+
144
+ return {
145
+ name,
146
+ path: absolutePath,
147
+ type,
148
+ duration,
149
+ width,
150
+ height,
151
+ rotation,
152
+ hasVideo,
153
+ hasAudio,
154
+ ...(author && { author }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Infers asset type from tag name or file path
160
+ */
161
+ private inferAssetType(
162
+ tagName: string,
163
+ path: string,
164
+ ): 'video' | 'image' | 'audio' {
165
+ // Check tag name first
166
+ if (tagName === 'video') return 'video';
167
+ if (tagName === 'img') return 'image';
168
+ if (tagName === 'audio') return 'audio';
169
+
170
+ // Check file extension
171
+ const ext = path.split('.').pop()?.toLowerCase() || '';
172
+ if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(ext)) return 'video';
173
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext))
174
+ return 'image';
175
+ if (['mp3', 'wav', 'ogg', 'aac', 'm4a'].includes(ext)) return 'audio';
176
+
177
+ // Default to video
178
+ return 'video';
179
+ }
180
+
181
+ /**
182
+ * Gets the duration of an asset file using ffprobe
183
+ * @param path - Path to the asset file
184
+ * @param type - Asset type (video, audio, or image)
185
+ * @returns Duration in milliseconds
186
+ */
187
+ private async getAssetDuration(
188
+ path: string,
189
+ type: 'video' | 'image' | 'audio',
190
+ ): Promise<number> {
191
+ // Images don't have duration, skip ffprobe
192
+ if (type === 'image') {
193
+ return 0;
194
+ }
195
+
196
+ try {
197
+ const { stdout } = await execFileAsync('ffprobe', [
198
+ '-v',
199
+ 'error',
200
+ '-show_entries',
201
+ 'format=duration',
202
+ '-of',
203
+ 'default=noprint_wrappers=1:nokey=1',
204
+ path,
205
+ ]);
206
+
207
+ const durationSeconds = parseFloat(stdout.trim());
208
+ if (isNaN(durationSeconds)) {
209
+ console.warn(`Could not parse duration for asset: ${path}`);
210
+ return 0;
211
+ }
212
+
213
+ return Math.round(durationSeconds * 1000);
214
+ } catch (error) {
215
+ console.error(`Failed to get duration for asset: ${path}`, error);
216
+ return 0;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Gets the rotation of an asset file using ffprobe
222
+ * @param path - Path to the asset file
223
+ * @param type - Asset type (video, audio, or image)
224
+ * @returns Rotation in degrees (0, 90, 180, 270)
225
+ */
226
+ private async getAssetRotation(
227
+ path: string,
228
+ type: 'video' | 'image' | 'audio',
229
+ ): Promise<number> {
230
+ // Audio files don't have rotation
231
+ if (type === 'audio') {
232
+ return 0;
233
+ }
234
+
235
+ try {
236
+ const { stdout } = await execFileAsync('ffprobe', [
237
+ '-v',
238
+ 'error',
239
+ '-select_streams',
240
+ 'v:0',
241
+ '-show_entries',
242
+ 'stream_side_data=rotation',
243
+ '-of',
244
+ 'default=noprint_wrappers=1:nokey=1',
245
+ path,
246
+ ]);
247
+
248
+ const rotation = parseInt(stdout.trim(), 10);
249
+
250
+ if (isNaN(rotation)) {
251
+ // No rotation metadata found
252
+ return 0;
253
+ }
254
+
255
+ // Normalize to 0, 90, 180, 270
256
+ const normalized = Math.abs(rotation) % 360;
257
+ return normalized;
258
+ } catch (error) {
259
+ // No rotation metadata or error - default to 0
260
+ return 0;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Gets the dimensions of an asset file using ffprobe
266
+ * @param path - Path to the asset file
267
+ * @param type - Asset type (video, audio, or image)
268
+ * @returns Object with width and height in pixels
269
+ */
270
+ private async getAssetDimensions(
271
+ path: string,
272
+ type: 'video' | 'image' | 'audio',
273
+ ): Promise<{ width: number; height: number }> {
274
+ // Audio files don't have dimensions
275
+ if (type === 'audio') {
276
+ return { width: 0, height: 0 };
277
+ }
278
+
279
+ try {
280
+ const { stdout } = await execFileAsync('ffprobe', [
281
+ '-v',
282
+ 'error',
283
+ '-select_streams',
284
+ 'v:0',
285
+ '-show_entries',
286
+ 'stream=width,height',
287
+ '-of',
288
+ 'csv=s=x:p=0',
289
+ path,
290
+ ]);
291
+
292
+ const dimensions = stdout.trim();
293
+ const [widthStr, heightStr] = dimensions.split('x');
294
+ const width = parseInt(widthStr, 10);
295
+ const height = parseInt(heightStr, 10);
296
+
297
+ if (isNaN(width) || isNaN(height)) {
298
+ console.warn(`Could not parse dimensions for asset: ${path}`);
299
+ return { width: 0, height: 0 };
300
+ }
301
+
302
+ return { width, height };
303
+ } catch (error) {
304
+ console.error(`Failed to get dimensions for asset: ${path}`, error);
305
+ return { width: 0, height: 0 };
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Checks if an asset file has a video stream using ffprobe
311
+ * @param _path - Path to the asset file (unused for now, type-based check)
312
+ * @param type - Asset type (video, audio, or image)
313
+ * @returns True if the asset has a video stream
314
+ */
315
+ private async getHasVideo(
316
+ _path: string,
317
+ type: 'video' | 'image' | 'audio',
318
+ ): Promise<boolean> {
319
+ // Audio files don't have video
320
+ if (type === 'audio') {
321
+ return false;
322
+ }
323
+
324
+ // Video and image files always have video
325
+ if (type === 'video' || type === 'image') {
326
+ return true;
327
+ }
328
+
329
+ return false;
330
+ }
331
+
332
+ /**
333
+ * Checks if an asset file has an audio stream using ffprobe
334
+ * @param path - Path to the asset file
335
+ * @param type - Asset type (video, audio, or image)
336
+ * @returns True if the asset has an audio stream
337
+ */
338
+ private async getHasAudio(
339
+ path: string,
340
+ type: 'video' | 'image' | 'audio',
341
+ ): Promise<boolean> {
342
+ // Images don't have audio
343
+ if (type === 'image') {
344
+ return false;
345
+ }
346
+
347
+ // Audio files always have audio
348
+ if (type === 'audio') {
349
+ return true;
350
+ }
351
+
352
+ // For video, probe for audio stream
353
+ try {
354
+ const { stdout } = await execFileAsync('ffprobe', [
355
+ '-v',
356
+ 'error',
357
+ '-select_streams',
358
+ 'a:0',
359
+ '-show_entries',
360
+ 'stream=codec_type',
361
+ '-of',
362
+ 'default=noprint_wrappers=1:nokey=1',
363
+ path,
364
+ ]);
365
+
366
+ // If we get output, an audio stream exists
367
+ return stdout.trim() === 'audio';
368
+ } catch (error) {
369
+ // No audio stream or error
370
+ return false;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Processes all output configurations from the parsed HTML
376
+ * Returns a map of output name => Output definition
377
+ */
378
+ private processOutputs(): Map<string, Output> {
379
+ const outputElements = this.findOutputElements();
380
+ const outputs = new Map<string, Output>();
381
+
382
+ // If no outputs found, create default
383
+ if (outputElements.length === 0) {
384
+ console.warn('No output elements found, using defaults');
385
+ const defaultOutput: Output = {
386
+ name: 'output',
387
+ path: resolve(this.projectDir, './output/video.mp4'),
388
+ resolution: { width: 1920, height: 1080 },
389
+ fps: 30,
390
+ };
391
+ outputs.set(defaultOutput.name, defaultOutput);
392
+ return outputs;
393
+ }
394
+
395
+ // Process each output element
396
+ for (const element of outputElements) {
397
+ const attrs = new Map(element.attrs.map((attr) => [attr.name, attr.value]));
398
+
399
+ // Extract name
400
+ const name = attrs.get('name') || 'output';
401
+
402
+ // Extract and resolve path
403
+ const relativePath = attrs.get('path') || `./output/${name}.mp4`;
404
+ const path = resolve(this.projectDir, relativePath);
405
+
406
+ // Extract and parse resolution (format: "1920x1080")
407
+ const resolutionStr = attrs.get('resolution') || '1920x1080';
408
+ const [widthStr, heightStr] = resolutionStr.split('x');
409
+ const resolution = {
410
+ width: parseInt(widthStr, 10) || 1920,
411
+ height: parseInt(heightStr, 10) || 1080,
412
+ };
413
+
414
+ // Extract fps
415
+ const fpsStr = attrs.get('fps');
416
+ const fps = fpsStr ? parseInt(fpsStr, 10) : 30;
417
+
418
+ const output: Output = {
419
+ name,
420
+ path,
421
+ resolution,
422
+ fps,
423
+ };
424
+
425
+ outputs.set(name, output);
426
+ }
427
+
428
+ return outputs;
429
+ }
430
+
431
+ /**
432
+ * Finds all output elements in the HTML
433
+ */
434
+ private findOutputElements(): Element[] {
435
+ const results: Element[] = [];
436
+
437
+ const traverse = (node: ASTNode) => {
438
+ if ('tagName' in node) {
439
+ const element = node as Element;
440
+
441
+ // Check if element is an <output> tag
442
+ if (element.tagName === 'output') {
443
+ results.push(element);
444
+ }
445
+ }
446
+
447
+ if ('childNodes' in node && node.childNodes) {
448
+ for (const child of node.childNodes) {
449
+ traverse(child);
450
+ }
451
+ }
452
+ };
453
+
454
+ traverse(this.html.ast);
455
+ return results;
456
+ }
457
+
458
+ /**
459
+ * Processes sequences and fragments from the parsed HTML
460
+ */
461
+ private processSequences(assets: Asset[]): SequenceDefinition[] {
462
+ const sequenceElements = this.findSequenceElements();
463
+ const sequences: SequenceDefinition[] = [];
464
+
465
+ const assetMap: Map<string, Asset> = new Map();
466
+ assets.forEach((ass) => assetMap.set(ass.name, ass));
467
+
468
+ for (const sequenceElement of sequenceElements) {
469
+ const fragmentElements = this.findFragmentChildren(sequenceElement);
470
+ const rawFragments: Array<
471
+ Fragment & {
472
+ overlayRight: number | CompiledExpression;
473
+ overlayZIndexRight: number;
474
+ }
475
+ > = [];
476
+
477
+ for (const fragmentElement of fragmentElements) {
478
+ const fragment = this.processFragment(fragmentElement, assetMap);
479
+ if (fragment) {
480
+ rawFragments.push(fragment);
481
+ }
482
+ }
483
+
484
+ // Normalize overlays: combine prev's overlayRight with current's overlayLeft
485
+ const fragments: Fragment[] = rawFragments.map((frag, idx) => {
486
+ const { overlayRight, overlayZIndexRight, ...rest } = frag;
487
+
488
+ if (idx === 0) {
489
+ // First fragment: keep overlayLeft as-is
490
+ return rest;
491
+ }
492
+
493
+ const prevOverlayRight = rawFragments[idx - 1].overlayRight;
494
+ const prevOverlayZIndexRight = rawFragments[idx - 1].overlayZIndexRight;
495
+
496
+ // Sum up overlayLeft with previous overlayRight
497
+ let normalizedOverlayLeft: number | CompiledExpression;
498
+ if (
499
+ typeof frag.overlayLeft === 'number' &&
500
+ typeof prevOverlayRight === 'number'
501
+ ) {
502
+ normalizedOverlayLeft = frag.overlayLeft + prevOverlayRight;
503
+ } else {
504
+ // If either is an expression, create a new calc() expression
505
+ const leftVal =
506
+ typeof frag.overlayLeft === 'number'
507
+ ? frag.overlayLeft.toString()
508
+ : frag.overlayLeft.original;
509
+ const rightVal =
510
+ typeof prevOverlayRight === 'number'
511
+ ? prevOverlayRight.toString()
512
+ : prevOverlayRight.original;
513
+ normalizedOverlayLeft = parseValueLazy(
514
+ `calc(${leftVal} + ${rightVal})`,
515
+ ) as CompiledExpression;
516
+ }
517
+
518
+ // OverlayZIndexLeft from previous fragment's overlayZIndexRight (negated), if not already set
519
+ // Note: overlayZIndexRight is negated as per spec (e.g. 100 becomes -100)
520
+ const normalizedOverlayZIndex =
521
+ frag.overlayZIndex !== 0
522
+ ? frag.overlayZIndex
523
+ : prevOverlayZIndexRight !== 0
524
+ ? -prevOverlayZIndexRight
525
+ : 0;
526
+
527
+ return {
528
+ ...rest,
529
+ overlayLeft: normalizedOverlayLeft,
530
+ overlayZIndex: normalizedOverlayZIndex,
531
+ };
532
+ });
533
+
534
+ sequences.push({ fragments });
535
+ }
536
+
537
+ return sequences;
538
+ }
539
+
540
+ /**
541
+ * Finds all sequence elements that are direct children of <project>
542
+ */
543
+ private findSequenceElements(): Element[] {
544
+ // First find the <project> element
545
+ const projectElement = this.findProjectElement();
546
+ if (!projectElement) {
547
+ console.warn('No <project> element found');
548
+ return [];
549
+ }
550
+
551
+ // Get direct sequence children only
552
+ const sequences: Element[] = [];
553
+ if ('childNodes' in projectElement && projectElement.childNodes) {
554
+ for (const child of projectElement.childNodes) {
555
+ if ('tagName' in child) {
556
+ const element = child as Element;
557
+ if (element.tagName === 'sequence') {
558
+ sequences.push(element);
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ return sequences;
565
+ }
566
+
567
+ /**
568
+ * Finds the <project> root element
569
+ */
570
+ private findProjectElement(): Element | null {
571
+ const traverse = (node: ASTNode): Element | null => {
572
+ if ('tagName' in node) {
573
+ const element = node as Element;
574
+ if (element.tagName === 'project') {
575
+ return element;
576
+ }
577
+ }
578
+
579
+ if ('childNodes' in node && node.childNodes) {
580
+ for (const child of node.childNodes) {
581
+ const result = traverse(child);
582
+ if (result) return result;
583
+ }
584
+ }
585
+
586
+ return null;
587
+ };
588
+
589
+ return traverse(this.html.ast);
590
+ }
591
+
592
+ /**
593
+ * Finds all fragment descendants of a sequence element (not just direct children)
594
+ * Parse5 treats self-closing custom tags as opening tags, nesting subsequent elements
595
+ */
596
+ private findFragmentChildren(sequenceElement: Element): Element[] {
597
+ const fragments: Element[] = [];
598
+
599
+ const traverse = (node: ASTNode) => {
600
+ if ('tagName' in node) {
601
+ const element = node as Element;
602
+ if (element.tagName === 'fragment') {
603
+ fragments.push(element);
604
+ }
605
+ }
606
+
607
+ if ('childNodes' in node && node.childNodes) {
608
+ for (const child of node.childNodes) {
609
+ traverse(child);
610
+ }
611
+ }
612
+ };
613
+
614
+ // Start traversing from the sequence element's children
615
+ if ('childNodes' in sequenceElement && sequenceElement.childNodes) {
616
+ for (const child of sequenceElement.childNodes) {
617
+ traverse(child);
618
+ }
619
+ }
620
+
621
+ return fragments;
622
+ }
623
+
624
+ /**
625
+ * Processes a single fragment element according to Parser.md specification
626
+ * Returns fragment with temporary overlayRight and overlayZIndexRight for normalization
627
+ */
628
+ private processFragment(
629
+ element: Element,
630
+ assets: Map<string, Asset>,
631
+ ): (Fragment & {
632
+ overlayRight: number | CompiledExpression;
633
+ overlayZIndexRight: number;
634
+ }) | null {
635
+ const attrs = new Map(element.attrs.map((attr) => [attr.name, attr.value]));
636
+ const styles = this.html.css.get(element) || {};
637
+
638
+ // 1. Extract fragment ID from id attribute or generate one
639
+ const id =
640
+ attrs.get('id') || `fragment_${Math.random().toString(36).substring(2, 11)}`;
641
+
642
+ // 2. Extract assetName from attribute or CSS -asset property
643
+ const assetName = attrs.get('data-asset') || styles['-asset'] || '';
644
+
645
+ // 3. Check enabled flag from display property
646
+ const enabled = this.parseEnabled(styles['display']);
647
+
648
+ // 4. Extract container if present (first one only)
649
+ const container = this.extractFragmentContainer(element);
650
+
651
+ // 5. Parse trimLeft from -trim-start property
652
+ const trimLeft = this.parseTrimStart(styles['-trim-start']);
653
+
654
+ // 6. Parse duration from -duration property
655
+ const duration = this.parseDurationProperty(
656
+ styles['-duration'],
657
+ assetName,
658
+ assets,
659
+ trimLeft,
660
+ );
661
+
662
+ // 7. Parse -offset-start for overlayLeft (can be number or expression)
663
+ const overlayLeft = this.parseOffsetStart(styles['-offset-start']);
664
+
665
+ // 8. Parse -offset-end for overlayRight (temporary, will be normalized)
666
+ const overlayRight = this.parseOffsetEnd(styles['-offset-end']);
667
+
668
+ // 9. Parse -overlay-start-z-index for overlayZIndex
669
+ const overlayZIndex = this.parseZIndex(styles['-overlay-start-z-index']);
670
+
671
+ // 10. Parse -overlay-end-z-index for overlayZIndexRight (temporary)
672
+ const overlayZIndexRight = this.parseZIndex(
673
+ styles['-overlay-end-z-index'],
674
+ );
675
+
676
+ // 11. Parse -transition-start
677
+ const transitionIn = this.parseTransitionProperty(
678
+ styles['-transition-start'],
679
+ );
680
+
681
+ // 12. Parse -transition-end
682
+ const transitionOut = this.parseTransitionProperty(styles['-transition-end']);
683
+
684
+ // 13. Parse -object-fit
685
+ const objectFitData = this.parseObjectFitProperty(styles['-object-fit']);
686
+
687
+ // 14. Parse -chromakey
688
+ const chromakeyData = this.parseChromakeyProperty(styles['-chromakey']);
689
+
690
+ // 15. Parse filter (for visual filters)
691
+ const visualFilter = this.parseVisualFilterProperty(styles['filter']);
692
+
693
+ return {
694
+ id,
695
+ enabled,
696
+ assetName,
697
+ duration,
698
+ trimLeft,
699
+ overlayLeft,
700
+ overlayZIndex,
701
+ overlayRight, // Temporary, will be normalized
702
+ overlayZIndexRight, // Temporary, will be normalized
703
+ transitionIn: transitionIn.name,
704
+ transitionInDuration: transitionIn.duration,
705
+ transitionOut: transitionOut.name,
706
+ transitionOutDuration: transitionOut.duration,
707
+ objectFit: objectFitData.objectFit,
708
+ objectFitContain: objectFitData.objectFitContain,
709
+ objectFitContainAmbientBlurStrength:
710
+ objectFitData.objectFitContainAmbientBlurStrength,
711
+ objectFitContainAmbientBrightness:
712
+ objectFitData.objectFitContainAmbientBrightness,
713
+ objectFitContainAmbientSaturation:
714
+ objectFitData.objectFitContainAmbientSaturation,
715
+ objectFitContainPillarboxColor:
716
+ objectFitData.objectFitContainPillarboxColor,
717
+ chromakey: chromakeyData.chromakey,
718
+ chromakeyBlend: chromakeyData.chromakeyBlend,
719
+ chromakeySimilarity: chromakeyData.chromakeySimilarity,
720
+ chromakeyColor: chromakeyData.chromakeyColor,
721
+ ...(visualFilter && { visualFilter }), // Add visualFilter if present
722
+ ...(container && { container }), // Add container if present
723
+ };
724
+ }
725
+
726
+ /**
727
+ * Parses filter property (for visual filters)
728
+ * Format: "<filter-name>"
729
+ * Example: "instagram-nashville", "instagram-moon"
730
+ */
731
+ private parseVisualFilterProperty(
732
+ visualFilter: string | undefined,
733
+ ): string | undefined {
734
+ if (!visualFilter) {
735
+ return undefined;
736
+ }
737
+
738
+ const trimmed = visualFilter.trim();
739
+
740
+ // Return the filter name as-is
741
+ // Validation will happen in the Stream.filter() method
742
+ return trimmed || undefined;
743
+ }
744
+
745
+ /**
746
+ * Extracts the first <container> child from a fragment element
747
+ */
748
+ private extractFragmentContainer(element: Element): Container | undefined {
749
+ // Find first container child
750
+ if (!('childNodes' in element) || !element.childNodes) {
751
+ return undefined;
752
+ }
753
+
754
+ for (const child of element.childNodes) {
755
+ if ('tagName' in child && child.tagName === 'container') {
756
+ const containerElement = child as Element;
757
+
758
+ // Get id attribute
759
+ const idAttr = containerElement.attrs.find((attr) => attr.name === 'id');
760
+ const id =
761
+ idAttr?.value || `container_${Math.random().toString(36).substring(2, 11)}`;
762
+
763
+ // Get innerHTML (serialize all children)
764
+ const htmlContent = this.serializeElement(containerElement);
765
+
766
+ return {
767
+ id,
768
+ htmlContent,
769
+ };
770
+ }
771
+ }
772
+
773
+ return undefined;
774
+ }
775
+
776
+ /**
777
+ * Serializes an element's children to HTML string
778
+ */
779
+ private serializeElement(element: Element): string {
780
+ let html = '';
781
+
782
+ const traverse = (node: ASTNode) => {
783
+ if ('nodeName' in node && node.nodeName === '#text') {
784
+ // Text node
785
+ if ('value' in node && typeof node.value === 'string') {
786
+ html += node.value;
787
+ }
788
+ } else if ('tagName' in node) {
789
+ // Element node
790
+ const el = node as Element;
791
+ html += `<${el.tagName}`;
792
+
793
+ // Add attributes
794
+ for (const attr of el.attrs) {
795
+ html += ` ${attr.name}="${attr.value}"`;
796
+ }
797
+
798
+ html += '>';
799
+
800
+ // Process children
801
+ if ('childNodes' in el && el.childNodes) {
802
+ for (const child of el.childNodes) {
803
+ traverse(child);
804
+ }
805
+ }
806
+
807
+ html += `</${el.tagName}>`;
808
+ }
809
+ };
810
+
811
+ // Serialize all children
812
+ if ('childNodes' in element && element.childNodes) {
813
+ for (const child of element.childNodes) {
814
+ traverse(child);
815
+ }
816
+ }
817
+
818
+ return html;
819
+ }
820
+
821
+ /**
822
+ * Splits a string by whitespace, handling CSS-tree's various number formatting quirks:
823
+ * - Recombines standalone minus signs: "- 0.1" → "-0.1"
824
+ * - Splits concatenated numbers: "25-0.1" → ["25", "-0.1"]
825
+ */
826
+ private splitCssValue(value: string): string[] {
827
+ const rawParts = value.split(/\s+/);
828
+ const parts: string[] = [];
829
+
830
+ for (let i = 0; i < rawParts.length; i++) {
831
+ const part = rawParts[i];
832
+
833
+ // Handle standalone minus sign followed by number
834
+ if (part === '-' && i + 1 < rawParts.length) {
835
+ parts.push('-' + rawParts[i + 1]);
836
+ i++; // skip next part
837
+ continue;
838
+ }
839
+
840
+ // Handle concatenated numbers like "25-0.1" → ["25", "-0.1"]
841
+ // Match: <number><minus><number>
842
+ const match = part.match(/^(\d+(?:\.\d+)?)(-.+)$/);
843
+ if (match) {
844
+ parts.push(match[1]); // first number
845
+ parts.push(match[2]); // negative number
846
+ continue;
847
+ }
848
+
849
+ parts.push(part);
850
+ }
851
+
852
+ return parts;
853
+ }
854
+
855
+ /**
856
+ * Parses the 'display' CSS property for the enabled flag
857
+ * display: none -> false, anything else -> true
858
+ */
859
+ private parseEnabled(display: string | undefined): boolean {
860
+ return display !== 'none';
861
+ }
862
+
863
+ /**
864
+ * Parses -trim-start property into trimLeft
865
+ * Cannot be negative
866
+ */
867
+ private parseTrimStart(trimStart: string | undefined): number {
868
+ if (!trimStart) {
869
+ return 0;
870
+ }
871
+
872
+ const value = this.parseMilliseconds(trimStart);
873
+ // Ensure non-negative as per spec
874
+ return Math.max(0, value);
875
+ }
876
+
877
+ /**
878
+ * Parses the -duration CSS property
879
+ * Can be: "auto", percentage (e.g. "100%", "50%"), or time value (e.g. "5000ms", "5s")
880
+ */
881
+ private parseDurationProperty(
882
+ duration: string | undefined,
883
+ assetName: string,
884
+ assets: Map<string, Asset>,
885
+ trimLeft: number,
886
+ ): number {
887
+ if (!duration || duration.trim() === 'auto') {
888
+ // Auto: use asset duration minus trim-start
889
+ const asset = assets.get(assetName);
890
+ if (!asset) {
891
+ return 0;
892
+ }
893
+ return Math.max(0, asset.duration - trimLeft);
894
+ }
895
+
896
+ // Handle percentage (e.g., "100%", "50%")
897
+ if (duration.endsWith('%')) {
898
+ const percentage = parseFloat(duration);
899
+ if (isNaN(percentage)) {
900
+ return 0;
901
+ }
902
+
903
+ const asset = assets.get(assetName);
904
+ if (!asset) {
905
+ return 0;
906
+ }
907
+
908
+ // Calculate percentage of asset duration (don't include trim)
909
+ return Math.round((asset.duration * percentage) / 100);
910
+ }
911
+
912
+ // Handle time value (e.g., "5000ms", "5s")
913
+ return this.parseMilliseconds(duration);
914
+ }
915
+
916
+ /**
917
+ * Parses time value into milliseconds
918
+ * Supports: "5s", "5000ms", "1.5s", etc.
919
+ */
920
+ private parseMilliseconds(value: string | undefined): number {
921
+ if (!value) {
922
+ return 0;
923
+ }
924
+
925
+ const trimmed = value.trim();
926
+
927
+ // Handle milliseconds (e.g., "5000ms")
928
+ if (trimmed.endsWith('ms')) {
929
+ const ms = parseFloat(trimmed);
930
+ if (!isNaN(ms)) {
931
+ return Math.round(ms);
932
+ }
933
+ }
934
+
935
+ // Handle seconds (e.g., "5s", "1.5s")
936
+ if (trimmed.endsWith('s')) {
937
+ const seconds = parseFloat(trimmed);
938
+ if (!isNaN(seconds)) {
939
+ return Math.round(seconds * 1000);
940
+ }
941
+ }
942
+
943
+ return 0;
944
+ }
945
+
946
+ /**
947
+ * Parses -offset-start into overlayLeft
948
+ * Can be a time value or a calc() expression
949
+ */
950
+ private parseOffsetStart(
951
+ offsetStart: string | undefined,
952
+ ): number | CompiledExpression {
953
+ if (!offsetStart) {
954
+ return 0;
955
+ }
956
+
957
+ const trimmed = offsetStart.trim();
958
+
959
+ // Check if it's a calc() expression
960
+ if (trimmed.startsWith('calc(')) {
961
+ try {
962
+ return parseValueLazy(trimmed) as CompiledExpression;
963
+ } catch (error) {
964
+ console.error(`Failed to parse -offset-start expression: ${trimmed}`, error);
965
+ return 0;
966
+ }
967
+ }
968
+
969
+ // Otherwise parse as time value
970
+ return this.parseMilliseconds(trimmed);
971
+ }
972
+
973
+ /**
974
+ * Parses -offset-end into overlayRight (for next fragment)
975
+ * Can be a time value or a calc() expression
976
+ */
977
+ private parseOffsetEnd(
978
+ offsetEnd: string | undefined,
979
+ ): number | CompiledExpression {
980
+ if (!offsetEnd) {
981
+ return 0;
982
+ }
983
+
984
+ const trimmed = offsetEnd.trim();
985
+
986
+ // Check if it's a calc() expression
987
+ if (trimmed.startsWith('calc(')) {
988
+ try {
989
+ return parseValueLazy(trimmed) as CompiledExpression;
990
+ } catch (error) {
991
+ console.error(`Failed to parse -offset-end expression: ${trimmed}`, error);
992
+ return 0;
993
+ }
994
+ }
995
+
996
+ // Otherwise parse as time value
997
+ return this.parseMilliseconds(trimmed);
998
+ }
999
+
1000
+ /**
1001
+ * Parses z-index values (-overlay-start-z-index, -overlay-end-z-index)
1002
+ */
1003
+ private parseZIndex(zIndex: string | undefined): number {
1004
+ if (!zIndex) {
1005
+ return 0;
1006
+ }
1007
+
1008
+ const parsed = parseInt(zIndex.trim(), 10);
1009
+ return isNaN(parsed) ? 0 : parsed;
1010
+ }
1011
+
1012
+ /**
1013
+ * Parses -transition-start or -transition-end
1014
+ * Format: "<transition-name> <duration>"
1015
+ * Example: "fade-in 5s", "fade-out 500ms"
1016
+ */
1017
+ private parseTransitionProperty(
1018
+ transition: string | undefined,
1019
+ ): { name: string; duration: number } {
1020
+ if (!transition) {
1021
+ return { name: '', duration: 0 };
1022
+ }
1023
+
1024
+ const trimmed = transition.trim();
1025
+ const parts = this.splitCssValue(trimmed);
1026
+
1027
+ if (parts.length === 0) {
1028
+ return { name: '', duration: 0 };
1029
+ }
1030
+
1031
+ // First part is transition name
1032
+ const name = parts[0];
1033
+
1034
+ // Second part is duration (if present)
1035
+ const duration = parts.length > 1 ? this.parseMilliseconds(parts[1]) : 0;
1036
+
1037
+ return { name, duration };
1038
+ }
1039
+
1040
+ /**
1041
+ * Parses -object-fit property
1042
+ * Format: "<type> <settings>"
1043
+ * Examples:
1044
+ * - "contain ambient 25 -0.1 0.7"
1045
+ * - "contain pillarbox #000000"
1046
+ * - "cover"
1047
+ */
1048
+ private parseObjectFitProperty(objectFit: string | undefined): {
1049
+ objectFit: 'cover' | 'contain';
1050
+ objectFitContain: 'ambient' | 'pillarbox';
1051
+ objectFitContainAmbientBlurStrength: number;
1052
+ objectFitContainAmbientBrightness: number;
1053
+ objectFitContainAmbientSaturation: number;
1054
+ objectFitContainPillarboxColor: string;
1055
+ } {
1056
+ // Defaults
1057
+ const defaults = {
1058
+ objectFit: 'cover' as 'cover' | 'contain',
1059
+ objectFitContain: 'ambient' as 'ambient' | 'pillarbox',
1060
+ objectFitContainAmbientBlurStrength: 20,
1061
+ objectFitContainAmbientBrightness: -0.3,
1062
+ objectFitContainAmbientSaturation: 0.8,
1063
+ objectFitContainPillarboxColor: '#000000',
1064
+ };
1065
+
1066
+ if (!objectFit) {
1067
+ return defaults;
1068
+ }
1069
+
1070
+ const trimmed = objectFit.trim();
1071
+ const parts = this.splitCssValue(trimmed);
1072
+
1073
+ if (parts.length === 0) {
1074
+ return defaults;
1075
+ }
1076
+
1077
+ const type = parts[0];
1078
+
1079
+ // Handle "cover"
1080
+ if (type === 'cover') {
1081
+ return { ...defaults, objectFit: 'cover' };
1082
+ }
1083
+
1084
+ // Handle "contain" with sub-options
1085
+ if (type === 'contain') {
1086
+ const subType = parts[1];
1087
+
1088
+ // "contain ambient <blur> <brightness> <saturation>"
1089
+ if (subType === 'ambient') {
1090
+ const blur = parts[2] ? parseFloat(parts[2]) : defaults.objectFitContainAmbientBlurStrength;
1091
+ const brightness = parts[3]
1092
+ ? parseFloat(parts[3])
1093
+ : defaults.objectFitContainAmbientBrightness;
1094
+ const saturation = parts[4]
1095
+ ? parseFloat(parts[4])
1096
+ : defaults.objectFitContainAmbientSaturation;
1097
+
1098
+ return {
1099
+ ...defaults,
1100
+ objectFit: 'contain',
1101
+ objectFitContain: 'ambient',
1102
+ objectFitContainAmbientBlurStrength: isNaN(blur) ? defaults.objectFitContainAmbientBlurStrength : blur,
1103
+ objectFitContainAmbientBrightness: isNaN(brightness)
1104
+ ? defaults.objectFitContainAmbientBrightness
1105
+ : brightness,
1106
+ objectFitContainAmbientSaturation: isNaN(saturation)
1107
+ ? defaults.objectFitContainAmbientSaturation
1108
+ : saturation,
1109
+ };
1110
+ }
1111
+
1112
+ // "contain pillarbox <color>"
1113
+ if (subType === 'pillarbox') {
1114
+ const color = parts[2] || defaults.objectFitContainPillarboxColor;
1115
+
1116
+ return {
1117
+ ...defaults,
1118
+ objectFit: 'contain',
1119
+ objectFitContain: 'pillarbox',
1120
+ objectFitContainPillarboxColor: color,
1121
+ };
1122
+ }
1123
+ }
1124
+
1125
+ // Default
1126
+ return defaults;
1127
+ }
1128
+
1129
+ /**
1130
+ * Parses -chromakey property
1131
+ * Format: "<blend> <similarity> <color>"
1132
+ * Example: "0.1 0.3 #00FF00", "hard good #00FF00", "soft loose #123abc45"
1133
+ * Blend: hard=0.0, smooth=0.1, soft=0.2
1134
+ * Similarity: strict=0.1, good=0.3, forgiving=0.5, loose=0.7
1135
+ */
1136
+ private parseChromakeyProperty(chromakey: string | undefined): {
1137
+ chromakey: boolean;
1138
+ chromakeyBlend: number;
1139
+ chromakeySimilarity: number;
1140
+ chromakeyColor: string;
1141
+ } {
1142
+ // Defaults
1143
+ const defaults = {
1144
+ chromakey: false,
1145
+ chromakeyBlend: 0,
1146
+ chromakeySimilarity: 0,
1147
+ chromakeyColor: '#00FF00',
1148
+ };
1149
+
1150
+ if (!chromakey) {
1151
+ return defaults;
1152
+ }
1153
+
1154
+ const trimmed = chromakey.trim();
1155
+ const parts = this.splitCssValue(trimmed);
1156
+
1157
+ if (parts.length < 3) {
1158
+ // Need at least 3 parts
1159
+ return defaults;
1160
+ }
1161
+
1162
+ // Parse blend (can be number or canned constant)
1163
+ let blend = parseFloat(parts[0]);
1164
+ if (isNaN(blend)) {
1165
+ // Try canned constant
1166
+ const blendStr = parts[0].toLowerCase();
1167
+ if (blendStr === 'hard') blend = 0.0;
1168
+ else if (blendStr === 'smooth') blend = 0.1;
1169
+ else if (blendStr === 'soft') blend = 0.2;
1170
+ else blend = 0.0;
1171
+ }
1172
+
1173
+ // Parse similarity (can be number or canned constant)
1174
+ let similarity = parseFloat(parts[1]);
1175
+ if (isNaN(similarity)) {
1176
+ // Try canned constant
1177
+ const similarityStr = parts[1].toLowerCase();
1178
+ if (similarityStr === 'strict') similarity = 0.1;
1179
+ else if (similarityStr === 'good') similarity = 0.3;
1180
+ else if (similarityStr === 'forgiving') similarity = 0.5;
1181
+ else if (similarityStr === 'loose') similarity = 0.7;
1182
+ else similarity = 0.3;
1183
+ }
1184
+
1185
+ // Parse color
1186
+ const color = parts[2] || defaults.chromakeyColor;
1187
+
1188
+ return {
1189
+ chromakey: true, // If -chromakey is defined, it's enabled
1190
+ chromakeyBlend: blend,
1191
+ chromakeySimilarity: similarity,
1192
+ chromakeyColor: color,
1193
+ };
1194
+ }
1195
+ }