@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.
@@ -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
+ });
@@ -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
  /**