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