@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.
- package/.prettierrc +8 -0
- package/Makefile +69 -0
- package/dist/asset-manager.d.ts +16 -0
- package/dist/asset-manager.d.ts.map +1 -0
- package/dist/asset-manager.js +50 -0
- package/dist/asset-manager.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/container-renderer.d.ts +21 -0
- package/dist/container-renderer.d.ts.map +1 -0
- package/dist/container-renderer.js +149 -0
- package/dist/container-renderer.js.map +1 -0
- package/dist/expression-parser.d.ts +63 -0
- package/dist/expression-parser.d.ts.map +1 -0
- package/dist/expression-parser.js +145 -0
- package/dist/expression-parser.js.map +1 -0
- package/dist/ffmpeg.d.ts +375 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +997 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/ffprobe.d.ts +2 -0
- package/dist/ffprobe.d.ts.map +1 -0
- package/dist/ffprobe.js +31 -0
- package/dist/ffprobe.js.map +1 -0
- package/dist/html-parser.d.ts +56 -0
- package/dist/html-parser.d.ts.map +1 -0
- package/dist/html-parser.js +208 -0
- package/dist/html-parser.js.map +1 -0
- package/dist/html-project-parser.d.ts +169 -0
- package/dist/html-project-parser.d.ts.map +1 -0
- package/dist/html-project-parser.js +954 -0
- package/dist/html-project-parser.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/label-generator.d.ts +35 -0
- package/dist/label-generator.d.ts.map +1 -0
- package/dist/label-generator.js +69 -0
- package/dist/label-generator.js.map +1 -0
- package/dist/project.d.ts +29 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +137 -0
- package/dist/project.js.map +1 -0
- package/dist/sample-sequences.d.ts +5 -0
- package/dist/sample-sequences.d.ts.map +1 -0
- package/dist/sample-sequences.js +199 -0
- package/dist/sample-sequences.js.map +1 -0
- package/dist/sample-streams.d.ts +2 -0
- package/dist/sample-streams.d.ts.map +1 -0
- package/dist/sample-streams.js +109 -0
- package/dist/sample-streams.js.map +1 -0
- package/dist/sequence.d.ts +21 -0
- package/dist/sequence.d.ts.map +1 -0
- package/dist/sequence.js +269 -0
- package/dist/sequence.js.map +1 -0
- package/dist/stream.d.ts +135 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +779 -0
- package/dist/stream.js.map +1 -0
- package/dist/type.d.ts +73 -0
- package/dist/type.d.ts.map +1 -0
- package/dist/type.js +3 -0
- package/dist/type.js.map +1 -0
- package/eslint.config.js +44 -0
- package/package.json +50 -0
- package/src/asset-manager.ts +55 -0
- package/src/cli.ts +306 -0
- package/src/container-renderer.ts +190 -0
- package/src/expression-parser.test.ts +459 -0
- package/src/expression-parser.ts +199 -0
- package/src/ffmpeg.ts +1403 -0
- package/src/ffprobe.ts +29 -0
- package/src/html-parser.ts +221 -0
- package/src/html-project-parser.ts +1195 -0
- package/src/index.ts +9 -0
- package/src/label-generator.ts +74 -0
- package/src/project.ts +180 -0
- package/src/sample-sequences.ts +225 -0
- package/src/sample-streams.ts +142 -0
- package/src/sequence.ts +330 -0
- package/src/stream.ts +1012 -0
- package/src/type.ts +81 -0
- 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
|
+
}
|