@gannochenko/staticstripes 0.0.18 → 0.0.20
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/dist/app-renderer.d.ts +29 -0
- package/dist/app-renderer.d.ts.map +1 -0
- package/dist/app-renderer.js +151 -0
- package/dist/app-renderer.js.map +1 -0
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +2 -1
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/html-parser.d.ts +5 -0
- package/dist/html-parser.d.ts.map +1 -1
- package/dist/html-parser.js +33 -1
- package/dist/html-parser.js.map +1 -1
- package/dist/html-project-parser.d.ts +11 -0
- package/dist/html-project-parser.d.ts.map +1 -1
- package/dist/html-project-parser.js +56 -6
- package/dist/html-project-parser.js.map +1 -1
- package/dist/project.d.ts +9 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +46 -1
- package/dist/project.js.map +1 -1
- package/dist/type.d.ts +6 -0
- package/dist/type.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/app-renderer.ts +229 -0
- package/src/cli/commands/generate.ts +2 -1
- package/src/duration-trim.test.ts +276 -0
- package/src/html-parser.ts +37 -1
- package/src/html-project-parser.ts +69 -3
- package/src/project.ts +72 -0
- package/src/type.ts +8 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import puppeteer, { Browser } from 'puppeteer';
|
|
2
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { resolve, isAbsolute } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { App } from './type';
|
|
7
|
+
|
|
8
|
+
const RENDER_TIMEOUT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
export interface RenderAppOptions {
|
|
11
|
+
app: App;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
projectDir: string;
|
|
15
|
+
outputName: string;
|
|
16
|
+
title: string;
|
|
17
|
+
date?: string;
|
|
18
|
+
tags: string[];
|
|
19
|
+
browser?: Browser; // optional shared browser instance
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AppRenderResult {
|
|
23
|
+
app: App;
|
|
24
|
+
screenshotPath: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function generateAppCacheKey(
|
|
28
|
+
src: string,
|
|
29
|
+
parameters: Record<string, string>,
|
|
30
|
+
title: string,
|
|
31
|
+
date: string | undefined,
|
|
32
|
+
tags: string[],
|
|
33
|
+
outputName: string,
|
|
34
|
+
): string {
|
|
35
|
+
const hash = createHash('sha256');
|
|
36
|
+
hash.update(src);
|
|
37
|
+
hash.update(JSON.stringify(parameters));
|
|
38
|
+
hash.update(title);
|
|
39
|
+
hash.update(date ?? '');
|
|
40
|
+
hash.update(tags.join(','));
|
|
41
|
+
hash.update(outputName);
|
|
42
|
+
return hash.digest('hex').substring(0, 16);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders a React (or any SPA) app to a PNG screenshot using Puppeteer.
|
|
47
|
+
* The app must dispatch a "sts-render-complete" custom event on document
|
|
48
|
+
* when it is fully rendered. If the event is not received within
|
|
49
|
+
* RENDER_TIMEOUT_MS, an error is thrown.
|
|
50
|
+
*/
|
|
51
|
+
export async function renderApp(options: RenderAppOptions): Promise<AppRenderResult> {
|
|
52
|
+
const { app, width, height, projectDir, outputName, title, date, tags, browser: sharedBrowser } = options;
|
|
53
|
+
|
|
54
|
+
// Create cache directory
|
|
55
|
+
const cacheDir = resolve(projectDir, 'cache', 'apps');
|
|
56
|
+
if (!existsSync(cacheDir)) {
|
|
57
|
+
await mkdir(cacheDir, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Generate cache key from all inputs that affect output
|
|
61
|
+
const cacheKey = generateAppCacheKey(
|
|
62
|
+
app.src,
|
|
63
|
+
app.parameters,
|
|
64
|
+
title,
|
|
65
|
+
date,
|
|
66
|
+
tags,
|
|
67
|
+
outputName,
|
|
68
|
+
);
|
|
69
|
+
const screenshotPath = resolve(cacheDir, `${cacheKey}.png`);
|
|
70
|
+
|
|
71
|
+
// Return cached result if available
|
|
72
|
+
if (existsSync(screenshotPath)) {
|
|
73
|
+
console.log(
|
|
74
|
+
`Using cached app "${app.id}" (hash: ${cacheKey}) from ${screenshotPath}`,
|
|
75
|
+
);
|
|
76
|
+
return { app, screenshotPath };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resolve index.html
|
|
80
|
+
const appDir = isAbsolute(app.src)
|
|
81
|
+
? app.src
|
|
82
|
+
: resolve(projectDir, app.src);
|
|
83
|
+
const indexPath = resolve(appDir, 'index.html');
|
|
84
|
+
|
|
85
|
+
if (!existsSync(indexPath)) {
|
|
86
|
+
throw new Error(`App "${app.id}": index.html not found at ${indexPath}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build URL with query parameters.
|
|
90
|
+
// Metadata (title, date, tags) is always injected; extra parameters from
|
|
91
|
+
// data-parameters are merged in afterwards and can override metadata keys.
|
|
92
|
+
const searchParams = new URLSearchParams({ rendering: '' });
|
|
93
|
+
searchParams.set('title', title);
|
|
94
|
+
if (date) searchParams.set('date', date);
|
|
95
|
+
if (tags.length > 0) searchParams.set('tags', tags.join(','));
|
|
96
|
+
for (const [key, value] of Object.entries(app.parameters)) {
|
|
97
|
+
searchParams.set(key, value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const url = `file://${indexPath}?${searchParams.toString()}`;
|
|
101
|
+
|
|
102
|
+
console.log(`\nRendering app "${app.id}" from ${url}`);
|
|
103
|
+
|
|
104
|
+
const ownBrowser = sharedBrowser
|
|
105
|
+
? null
|
|
106
|
+
: await puppeteer.launch({
|
|
107
|
+
headless: true,
|
|
108
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
109
|
+
});
|
|
110
|
+
const browser = sharedBrowser ?? ownBrowser!;
|
|
111
|
+
|
|
112
|
+
const page = await browser.newPage();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await page.setViewport({ width, height });
|
|
116
|
+
|
|
117
|
+
page.on('console', (msg) =>
|
|
118
|
+
console.log(`[app:${app.id}] console.${msg.type()}: ${msg.text()}`),
|
|
119
|
+
);
|
|
120
|
+
page.on('pageerror', (err) =>
|
|
121
|
+
console.error(`[app:${app.id}] page error: ${String(err)}`),
|
|
122
|
+
);
|
|
123
|
+
page.on('requestfailed', (req) =>
|
|
124
|
+
console.error(`[app:${app.id}] request failed: ${req.url()} — ${req.failure()?.errorText}`),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Initialise the flag before navigation. The app sets it to true directly
|
|
128
|
+
// via window.__stsRenderComplete = true inside its useEffect, so no event
|
|
129
|
+
// listener is needed on this side.
|
|
130
|
+
await page.evaluateOnNewDocument(
|
|
131
|
+
`window.__stsRenderComplete = false;`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await page.goto(url, { waitUntil: 'networkidle0' });
|
|
135
|
+
|
|
136
|
+
// Wait for the app to signal it is done rendering
|
|
137
|
+
await page
|
|
138
|
+
.waitForFunction('window.__stsRenderComplete === true', {
|
|
139
|
+
timeout: RENDER_TIMEOUT_MS,
|
|
140
|
+
})
|
|
141
|
+
.catch(() => {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`App "${app.id}" did not set window.__stsRenderComplete within ${RENDER_TIMEOUT_MS}ms`,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Screenshot with transparent background
|
|
148
|
+
const screenshot = await page.screenshot({
|
|
149
|
+
type: 'png',
|
|
150
|
+
omitBackground: true,
|
|
151
|
+
clip: { x: 0, y: 0, width, height },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await writeFile(screenshotPath, screenshot);
|
|
155
|
+
|
|
156
|
+
console.log(
|
|
157
|
+
`Rendered app "${app.id}" (hash: ${cacheKey}) to ${screenshotPath}`,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return { app, screenshotPath };
|
|
161
|
+
} finally {
|
|
162
|
+
await page.close();
|
|
163
|
+
if (ownBrowser) await ownBrowser.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Renders multiple apps in sequence, reusing a single browser instance.
|
|
169
|
+
*/
|
|
170
|
+
export async function renderApps(
|
|
171
|
+
apps: App[],
|
|
172
|
+
width: number,
|
|
173
|
+
height: number,
|
|
174
|
+
projectDir: string,
|
|
175
|
+
outputName: string,
|
|
176
|
+
title: string,
|
|
177
|
+
date: string | undefined,
|
|
178
|
+
tags: string[],
|
|
179
|
+
activeCacheKeys?: Set<string>,
|
|
180
|
+
): Promise<AppRenderResult[]> {
|
|
181
|
+
const results: AppRenderResult[] = [];
|
|
182
|
+
|
|
183
|
+
// Launch once and reuse across all apps.
|
|
184
|
+
// --allow-file-access-from-files is required so Chromium allows
|
|
185
|
+
// <script type="module"> and <link> tags to load sibling files
|
|
186
|
+
// when the page itself is served via file://.
|
|
187
|
+
const browser = await puppeteer.launch({
|
|
188
|
+
headless: true,
|
|
189
|
+
args: [
|
|
190
|
+
'--no-sandbox',
|
|
191
|
+
'--disable-setuid-sandbox',
|
|
192
|
+
'--allow-file-access-from-files',
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
for (const app of apps) {
|
|
198
|
+
const cacheKey = generateAppCacheKey(
|
|
199
|
+
app.src,
|
|
200
|
+
app.parameters,
|
|
201
|
+
title,
|
|
202
|
+
date,
|
|
203
|
+
tags,
|
|
204
|
+
outputName,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (activeCacheKeys) {
|
|
208
|
+
activeCacheKeys.add(cacheKey);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const result = await renderApp({
|
|
212
|
+
app,
|
|
213
|
+
width,
|
|
214
|
+
height,
|
|
215
|
+
projectDir,
|
|
216
|
+
outputName,
|
|
217
|
+
title,
|
|
218
|
+
date,
|
|
219
|
+
tags,
|
|
220
|
+
browser,
|
|
221
|
+
});
|
|
222
|
+
results.push(result);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
await browser.close();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return results;
|
|
229
|
+
}
|
|
@@ -158,8 +158,9 @@ export function registerGenerateCommand(
|
|
|
158
158
|
mkdirSync(outputDir, { recursive: true });
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
// Render containers for this output (accumulate cache keys)
|
|
161
|
+
// Render containers and apps for this output (accumulate cache keys)
|
|
162
162
|
await project.renderContainers(outputName, activeCacheKeys);
|
|
163
|
+
await project.renderApps(outputName, activeCacheKeys);
|
|
163
164
|
|
|
164
165
|
// Print project statistics
|
|
165
166
|
project.printStats();
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { HTMLParser } from './html-parser';
|
|
3
|
+
|
|
4
|
+
describe('Duration and Trim Properties - CSS Parsing and Logic', () => {
|
|
5
|
+
// Helper to extract computed CSS styles for a fragment
|
|
6
|
+
const parseFragmentStyles = (html: string) => {
|
|
7
|
+
const fullHtml = html.includes('<title>') ? html : `<title>Test</title>${html}`;
|
|
8
|
+
const htmlParser = new HTMLParser();
|
|
9
|
+
const parsed = htmlParser.parse(fullHtml);
|
|
10
|
+
|
|
11
|
+
// Find the fragment element
|
|
12
|
+
const findFragment = (node: any): any => {
|
|
13
|
+
if (node.type === 'tag' && node.name === 'fragment') return node;
|
|
14
|
+
if (node.children) {
|
|
15
|
+
for (const child of node.children) {
|
|
16
|
+
const result = findFragment(child);
|
|
17
|
+
if (result) return result;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const fragment = findFragment(parsed.ast);
|
|
24
|
+
if (!fragment) {
|
|
25
|
+
throw new Error('No fragment found in HTML');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return parsed.css.get(fragment) || {};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Helper to parse time value from CSS
|
|
32
|
+
const parseTime = (value: string | undefined): number => {
|
|
33
|
+
if (!value) return 0;
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
|
|
36
|
+
if (trimmed.endsWith('ms')) {
|
|
37
|
+
const ms = parseFloat(trimmed);
|
|
38
|
+
return isNaN(ms) ? 0 : Math.round(ms);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (trimmed.endsWith('s')) {
|
|
42
|
+
const seconds = parseFloat(trimmed);
|
|
43
|
+
return isNaN(seconds) ? 0 : Math.round(seconds * 1000);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 0;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Helper to test duration calculation (replicates parseDurationProperty logic)
|
|
50
|
+
const calculateDuration = (
|
|
51
|
+
trimStart: number,
|
|
52
|
+
trimEnd: number,
|
|
53
|
+
durationValue: string | undefined,
|
|
54
|
+
assetDuration: number,
|
|
55
|
+
): number => {
|
|
56
|
+
if (!durationValue || durationValue.trim() === 'auto') {
|
|
57
|
+
return Math.max(0, assetDuration - trimStart - trimEnd);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Handle percentage
|
|
61
|
+
if (durationValue.endsWith('%')) {
|
|
62
|
+
const percentage = parseFloat(durationValue);
|
|
63
|
+
return Math.round((assetDuration * percentage) / 100);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Parse explicit time value
|
|
67
|
+
return parseTime(durationValue);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe('CSS Property Parsing', () => {
|
|
71
|
+
it('should parse -duration property', () => {
|
|
72
|
+
const html = `
|
|
73
|
+
<project><sequence><fragment class="test" /></sequence></project>
|
|
74
|
+
<style>.test { -duration: 5000ms; }</style>
|
|
75
|
+
`;
|
|
76
|
+
const styles = parseFragmentStyles(html);
|
|
77
|
+
expect(styles['-duration']).toBe('5000ms');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should parse -trim-start property', () => {
|
|
81
|
+
const html = `
|
|
82
|
+
<project><sequence><fragment class="test" /></sequence></project>
|
|
83
|
+
<style>.test { -trim-start: 2000ms; }</style>
|
|
84
|
+
`;
|
|
85
|
+
const styles = parseFragmentStyles(html);
|
|
86
|
+
expect(styles['-trim-start']).toBe('2000ms');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should parse -trim-end property', () => {
|
|
90
|
+
const html = `
|
|
91
|
+
<project><sequence><fragment class="test" /></sequence></project>
|
|
92
|
+
<style>.test { -trim-end: 3000ms; }</style>
|
|
93
|
+
`;
|
|
94
|
+
const styles = parseFragmentStyles(html);
|
|
95
|
+
expect(styles['-trim-end']).toBe('3000ms');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should parse all three properties together', () => {
|
|
99
|
+
const html = `
|
|
100
|
+
<project><sequence><fragment class="test" /></sequence></project>
|
|
101
|
+
<style>.test { -trim-start: 2s; -trim-end: 3s; -duration: auto; }</style>
|
|
102
|
+
`;
|
|
103
|
+
const styles = parseFragmentStyles(html);
|
|
104
|
+
expect(styles['-trim-start']).toBe('2s');
|
|
105
|
+
expect(styles['-trim-end']).toBe('3s');
|
|
106
|
+
expect(styles['-duration']).toBe('auto');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should allow inline styles to override class styles', () => {
|
|
110
|
+
const html = `
|
|
111
|
+
<project><sequence>
|
|
112
|
+
<fragment class="test" style="-duration: 2000ms; -trim-start: 500ms;" />
|
|
113
|
+
</sequence></project>
|
|
114
|
+
<style>.test { -duration: 5000ms; -trim-start: 3000ms; }</style>
|
|
115
|
+
`;
|
|
116
|
+
const styles = parseFragmentStyles(html);
|
|
117
|
+
expect(styles['-duration']).toBe('2000ms'); // Inline overrides
|
|
118
|
+
expect(styles['-trim-start']).toBe('500ms'); // Inline overrides
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('Duration Calculation - No Trim', () => {
|
|
123
|
+
it('should use explicit duration', () => {
|
|
124
|
+
const result = calculateDuration(0, 0, '5000ms', 10000);
|
|
125
|
+
expect(result).toBe(5000);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should use auto duration (full asset)', () => {
|
|
129
|
+
const result = calculateDuration(0, 0, 'auto', 10000);
|
|
130
|
+
expect(result).toBe(10000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should handle percentage duration', () => {
|
|
134
|
+
const result = calculateDuration(0, 0, '50%', 10000);
|
|
135
|
+
expect(result).toBe(5000);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should handle undefined duration (auto)', () => {
|
|
139
|
+
const result = calculateDuration(0, 0, undefined, 8000);
|
|
140
|
+
expect(result).toBe(8000);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('Duration Calculation - With trim-start Only', () => {
|
|
145
|
+
it('should use explicit duration with trim-start', () => {
|
|
146
|
+
const result = calculateDuration(2000, 0, '3000ms', 10000);
|
|
147
|
+
expect(result).toBe(3000);
|
|
148
|
+
// FFmpeg: start=2000, end=5000 (2000+3000)
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should calculate auto duration with trim-start', () => {
|
|
152
|
+
const result = calculateDuration(2000, 0, 'auto', 10000);
|
|
153
|
+
expect(result).toBe(8000); // 10000 - 2000
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should handle undefined duration with trim-start', () => {
|
|
157
|
+
const result = calculateDuration(3000, 0, undefined, 10000);
|
|
158
|
+
expect(result).toBe(7000); // 10000 - 3000
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('Duration Calculation - With trim-end Only', () => {
|
|
163
|
+
it('should use explicit duration (trim-end ignored)', () => {
|
|
164
|
+
const result = calculateDuration(0, 3000, '5000ms', 10000);
|
|
165
|
+
expect(result).toBe(5000); // Explicit, trim-end doesn't apply
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should calculate auto duration with trim-end', () => {
|
|
169
|
+
const result = calculateDuration(0, 3000, 'auto', 10000);
|
|
170
|
+
expect(result).toBe(7000); // 10000 - 3000
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle undefined duration with trim-end', () => {
|
|
174
|
+
const result = calculateDuration(0, 4000, undefined, 10000);
|
|
175
|
+
expect(result).toBe(6000); // 10000 - 4000
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('Duration Calculation - With Both Trims', () => {
|
|
180
|
+
it('should use explicit duration (both trims ignored)', () => {
|
|
181
|
+
const result = calculateDuration(2000, 3000, '4000ms', 10000);
|
|
182
|
+
expect(result).toBe(4000);
|
|
183
|
+
// FFmpeg: start=2000, end=6000 (2000+4000)
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should calculate auto duration with both trims', () => {
|
|
187
|
+
const result = calculateDuration(2000, 3000, 'auto', 10000);
|
|
188
|
+
expect(result).toBe(5000); // 10000 - 2000 - 3000
|
|
189
|
+
// FFmpeg: start=2000, end=7000 (2000+5000)
|
|
190
|
+
// Shows content from 2s to 7s
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should handle undefined duration with both trims', () => {
|
|
194
|
+
const result = calculateDuration(1000, 2000, undefined, 8000);
|
|
195
|
+
expect(result).toBe(5000); // 8000 - 1000 - 2000
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle trims exceeding asset duration', () => {
|
|
199
|
+
const result = calculateDuration(6000, 5000, 'auto', 10000);
|
|
200
|
+
expect(result).toBe(0); // Math.max(0, 10000 - 6000 - 5000)
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should handle very large trim-start', () => {
|
|
204
|
+
const result = calculateDuration(15000, 0, 'auto', 10000);
|
|
205
|
+
expect(result).toBe(0); // Math.max(0, 10000 - 15000)
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('Time Parsing', () => {
|
|
210
|
+
it('should parse milliseconds', () => {
|
|
211
|
+
expect(parseTime('5000ms')).toBe(5000);
|
|
212
|
+
expect(parseTime('1500ms')).toBe(1500);
|
|
213
|
+
expect(parseTime('0ms')).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should parse seconds', () => {
|
|
217
|
+
expect(parseTime('5s')).toBe(5000);
|
|
218
|
+
expect(parseTime('1.5s')).toBe(1500);
|
|
219
|
+
expect(parseTime('0s')).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should handle undefined', () => {
|
|
223
|
+
expect(parseTime(undefined)).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should handle whitespace', () => {
|
|
227
|
+
expect(parseTime(' 5000ms ')).toBe(5000);
|
|
228
|
+
expect(parseTime(' 2s ')).toBe(2000);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('Real-World Scenarios', () => {
|
|
233
|
+
it('should handle typical trim scenario: skip intro and outro', () => {
|
|
234
|
+
// 60-second GoPro clip, skip 5s intro and 3s outro
|
|
235
|
+
const result = calculateDuration(5000, 3000, 'auto', 60000);
|
|
236
|
+
expect(result).toBe(52000); // 60s - 5s - 3s = 52s
|
|
237
|
+
// Shows content from 5s to 57s
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should handle user-specified exact duration with trim', () => {
|
|
241
|
+
// 5-minute interview, start at 10s, use 30s
|
|
242
|
+
const result = calculateDuration(10000, 0, '30000ms', 300000);
|
|
243
|
+
expect(result).toBe(30000);
|
|
244
|
+
// Shows content from 10s to 40s
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle percentage with trims', () => {
|
|
248
|
+
// Use middle 50% of a 10s clip
|
|
249
|
+
const result = calculateDuration(0, 0, '50%', 10000);
|
|
250
|
+
expect(result).toBe(5000);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('Edge Cases', () => {
|
|
255
|
+
it('should handle zero duration', () => {
|
|
256
|
+
const result = calculateDuration(0, 0, '0ms', 10000);
|
|
257
|
+
expect(result).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should handle zero trim values', () => {
|
|
261
|
+
const result = calculateDuration(0, 0, 'auto', 10000);
|
|
262
|
+
expect(result).toBe(10000);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should handle 100% percentage', () => {
|
|
266
|
+
const result = calculateDuration(0, 0, '100%', 10000);
|
|
267
|
+
expect(result).toBe(10000);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should handle negative percentage', () => {
|
|
271
|
+
const result = calculateDuration(0, 0, '-10%', 10000);
|
|
272
|
+
// parseFloat('-10%') = -10, so Math.round((10000 * -10) / 100) = -1000
|
|
273
|
+
expect(result).toBe(-1000);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/html-parser.ts
CHANGED
|
@@ -136,13 +136,20 @@ export class HTMLParser {
|
|
|
136
136
|
const element = currentNode as Element;
|
|
137
137
|
const computedStyles: CSSProperties = {};
|
|
138
138
|
|
|
139
|
-
// Apply matching rules
|
|
139
|
+
// Apply matching rules (CSS classes)
|
|
140
140
|
for (const rule of styleRules) {
|
|
141
141
|
if (this.matchesSelector(element, rule.selector)) {
|
|
142
142
|
Object.assign(computedStyles, rule.properties);
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Apply inline style attribute (highest priority)
|
|
147
|
+
const inlineStyle = element.attribs?.style;
|
|
148
|
+
if (inlineStyle) {
|
|
149
|
+
const inlineProperties = this.parseInlineStyle(inlineStyle);
|
|
150
|
+
Object.assign(computedStyles, inlineProperties);
|
|
151
|
+
}
|
|
152
|
+
|
|
146
153
|
elementsMap.set(element, computedStyles);
|
|
147
154
|
}
|
|
148
155
|
|
|
@@ -155,6 +162,35 @@ export class HTMLParser {
|
|
|
155
162
|
|
|
156
163
|
traverse(node);
|
|
157
164
|
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parses inline style attribute into CSS properties
|
|
168
|
+
* Example: "color: red; font-size: 16px;" => { color: "red", "font-size": "16px" }
|
|
169
|
+
*/
|
|
170
|
+
private parseInlineStyle(styleText: string): CSSProperties {
|
|
171
|
+
const properties: CSSProperties = {};
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// Wrap in a dummy rule for CSS parsing
|
|
175
|
+
const css = `.dummy { ${styleText} }`;
|
|
176
|
+
const ast = csstree.parse(css);
|
|
177
|
+
|
|
178
|
+
csstree.walk(ast, {
|
|
179
|
+
visit: 'Declaration',
|
|
180
|
+
enter: (node) => {
|
|
181
|
+
const decl = node as csstree.Declaration;
|
|
182
|
+
const property = decl.property;
|
|
183
|
+
const value = csstree.generate(decl.value);
|
|
184
|
+
properties[property] = value;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// If parsing fails, log and continue without inline styles
|
|
189
|
+
console.warn(`Failed to parse inline style: "${styleText}"`, error);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return properties;
|
|
193
|
+
}
|
|
158
194
|
}
|
|
159
195
|
|
|
160
196
|
/**
|