@codellyson/framely-cli 0.1.0

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 (40) hide show
  1. package/commands/compositions.js +135 -0
  2. package/commands/preview.js +889 -0
  3. package/commands/render.js +295 -0
  4. package/commands/still.js +165 -0
  5. package/index.js +93 -0
  6. package/package.json +60 -0
  7. package/studio/App.css +605 -0
  8. package/studio/App.jsx +185 -0
  9. package/studio/CompositionsView.css +399 -0
  10. package/studio/CompositionsView.jsx +327 -0
  11. package/studio/PropsEditor.css +195 -0
  12. package/studio/PropsEditor.tsx +176 -0
  13. package/studio/RenderDialog.tsx +476 -0
  14. package/studio/ShareDialog.tsx +200 -0
  15. package/studio/index.ts +19 -0
  16. package/studio/player/Player.css +199 -0
  17. package/studio/player/Player.jsx +355 -0
  18. package/studio/styles/design-system.css +592 -0
  19. package/studio/styles/dialogs.css +420 -0
  20. package/studio/templates/AnimatedGradient.jsx +99 -0
  21. package/studio/templates/InstagramStory.jsx +172 -0
  22. package/studio/templates/LowerThird.jsx +139 -0
  23. package/studio/templates/ProductShowcase.jsx +162 -0
  24. package/studio/templates/SlideTransition.jsx +211 -0
  25. package/studio/templates/SocialIntro.jsx +122 -0
  26. package/studio/templates/SubscribeAnimation.jsx +186 -0
  27. package/studio/templates/TemplateCard.tsx +58 -0
  28. package/studio/templates/TemplateFilters.tsx +97 -0
  29. package/studio/templates/TemplatePreviewDialog.tsx +196 -0
  30. package/studio/templates/TemplatesMarketplace.css +686 -0
  31. package/studio/templates/TemplatesMarketplace.tsx +172 -0
  32. package/studio/templates/TextReveal.jsx +134 -0
  33. package/studio/templates/UseTemplateDialog.tsx +154 -0
  34. package/studio/templates/index.ts +45 -0
  35. package/utils/browser.js +188 -0
  36. package/utils/codecs.js +200 -0
  37. package/utils/logger.js +35 -0
  38. package/utils/props.js +42 -0
  39. package/utils/render.js +447 -0
  40. package/utils/validate.js +148 -0
@@ -0,0 +1,172 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { templatesApi } from '@codellyson/framely';
3
+ import type { Template, TemplatesFilterParams } from '@codellyson/framely';
4
+ import { TemplateCard } from './TemplateCard';
5
+ import { TemplateFilters } from './TemplateFilters';
6
+ import { TemplatePreviewDialog } from './TemplatePreviewDialog';
7
+ import { UseTemplateDialog } from './UseTemplateDialog';
8
+ import './TemplatesMarketplace.css';
9
+
10
+ export interface TemplatesMarketplaceProps {
11
+ onUseTemplate: (template: Template, customId: string, customProps?: Record<string, unknown>) => void;
12
+ }
13
+
14
+ /**
15
+ * Main marketplace view for browsing templates
16
+ */
17
+ export function TemplatesMarketplace({ onUseTemplate }: TemplatesMarketplaceProps) {
18
+ const [templates, setTemplates] = useState<Template[]>([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [filters, setFilters] = useState<TemplatesFilterParams>({
22
+ sortBy: 'newest',
23
+ page: 1,
24
+ pageSize: 12,
25
+ });
26
+ const [hasMore, setHasMore] = useState(false);
27
+ const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
28
+ const [previewOpen, setPreviewOpen] = useState(false);
29
+ const [useDialogOpen, setUseDialogOpen] = useState(false);
30
+
31
+ // Fetch templates
32
+ const fetchTemplates = useCallback(
33
+ async (reset = false) => {
34
+ setLoading(true);
35
+ setError(null);
36
+ try {
37
+ const response = await templatesApi.getTemplates(filters);
38
+ setTemplates((prev) => (reset ? response.templates : [...prev, ...response.templates]));
39
+ setHasMore(response.hasMore);
40
+ } catch (err) {
41
+ setError(err instanceof Error ? err.message : 'Failed to load templates');
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ },
46
+ [filters]
47
+ );
48
+
49
+ // Fetch on filter change
50
+ useEffect(() => {
51
+ fetchTemplates(true);
52
+ }, [filters.category, filters.search, filters.sortBy]);
53
+
54
+ // Handle filter changes
55
+ const handleFilterChange = useCallback((newFilters: Partial<TemplatesFilterParams>) => {
56
+ setFilters((prev) => ({ ...prev, ...newFilters, page: 1 }));
57
+ }, []);
58
+
59
+ // Handle template click
60
+ const handleTemplateClick = useCallback((template: Template) => {
61
+ setSelectedTemplate(template);
62
+ setPreviewOpen(true);
63
+ }, []);
64
+
65
+ // Handle "Use Template" from preview
66
+ const handleUseFromPreview = useCallback(() => {
67
+ setPreviewOpen(false);
68
+ setUseDialogOpen(true);
69
+ }, []);
70
+
71
+ // Confirm using template
72
+ const handleConfirmUse = useCallback(
73
+ (customId: string, customProps: Record<string, unknown>) => {
74
+ if (selectedTemplate) {
75
+ onUseTemplate(selectedTemplate, customId, customProps);
76
+ setUseDialogOpen(false);
77
+ setSelectedTemplate(null);
78
+ }
79
+ },
80
+ [selectedTemplate, onUseTemplate]
81
+ );
82
+
83
+ // Load more templates
84
+ const handleLoadMore = useCallback(() => {
85
+ setFilters((prev) => ({ ...prev, page: (prev.page || 1) + 1 }));
86
+ }, []);
87
+
88
+ // Load more when page changes
89
+ useEffect(() => {
90
+ if (filters.page && filters.page > 1) {
91
+ fetchTemplates(false);
92
+ }
93
+ }, [filters.page]);
94
+
95
+ return (
96
+ <div className="templates-marketplace">
97
+ {/* Header */}
98
+ <div className="templates-header">
99
+ <h2>Templates Marketplace</h2>
100
+ <p className="templates-subtitle">
101
+ Browse and use professionally designed video templates
102
+ </p>
103
+ </div>
104
+
105
+ {/* Filters */}
106
+ <TemplateFilters filters={filters} onChange={handleFilterChange} />
107
+
108
+ {/* Content */}
109
+ {error ? (
110
+ <div className="templates-error">
111
+ <p>{error}</p>
112
+ <button type="button" onClick={() => fetchTemplates(true)}>
113
+ Retry
114
+ </button>
115
+ </div>
116
+ ) : (
117
+ <>
118
+ <div className="templates-grid">
119
+ {templates.map((template) => (
120
+ <TemplateCard
121
+ key={template.id}
122
+ template={template}
123
+ onClick={() => handleTemplateClick(template)}
124
+ />
125
+ ))}
126
+ {loading &&
127
+ Array.from({ length: 4 }).map((_, i) => (
128
+ <div key={`skeleton-${i}`} className="template-card-skeleton" />
129
+ ))}
130
+ </div>
131
+
132
+ {!loading && templates.length === 0 && (
133
+ <div className="templates-empty">
134
+ <p>No templates found</p>
135
+ {filters.search && (
136
+ <p className="templates-empty-hint">
137
+ Try adjusting your search or filters
138
+ </p>
139
+ )}
140
+ </div>
141
+ )}
142
+
143
+ {hasMore && !loading && (
144
+ <div className="templates-load-more">
145
+ <button type="button" onClick={handleLoadMore}>
146
+ Load More
147
+ </button>
148
+ </div>
149
+ )}
150
+ </>
151
+ )}
152
+
153
+ {/* Preview Dialog */}
154
+ <TemplatePreviewDialog
155
+ open={previewOpen}
156
+ template={selectedTemplate}
157
+ onClose={() => setPreviewOpen(false)}
158
+ onUseTemplate={handleUseFromPreview}
159
+ />
160
+
161
+ {/* Use Template Dialog */}
162
+ <UseTemplateDialog
163
+ open={useDialogOpen}
164
+ template={selectedTemplate}
165
+ onClose={() => setUseDialogOpen(false)}
166
+ onConfirm={handleConfirmUse}
167
+ />
168
+ </div>
169
+ );
170
+ }
171
+
172
+ export default TemplatesMarketplace;
@@ -0,0 +1,134 @@
1
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from '@codellyson/framely';
2
+
3
+ /**
4
+ * Kinetic Text Reveal Template
5
+ */
6
+ export function TextReveal({
7
+ text = 'Your text here',
8
+ fontSize = 120,
9
+ color = '#ffffff',
10
+ backgroundColor = '#000000',
11
+ }) {
12
+ const frame = useCurrentFrame();
13
+ const { width } = useVideoConfig();
14
+
15
+ const characters = text.split('');
16
+ const charDelay = 3; // frames between each character
17
+
18
+ return (
19
+ <AbsoluteFill
20
+ style={{
21
+ background: backgroundColor,
22
+ display: 'flex',
23
+ alignItems: 'center',
24
+ justifyContent: 'center',
25
+ overflow: 'hidden',
26
+ }}
27
+ >
28
+ {/* Background animated lines */}
29
+ {[...Array(5)].map((_, i) => {
30
+ const lineY = interpolate(
31
+ frame,
32
+ [i * 10, i * 10 + 60],
33
+ [-50, 110],
34
+ { extrapolateRight: 'clamp' }
35
+ );
36
+ return (
37
+ <div
38
+ key={i}
39
+ style={{
40
+ position: 'absolute',
41
+ left: 0,
42
+ right: 0,
43
+ top: `${lineY}%`,
44
+ height: 1,
45
+ background: `linear-gradient(90deg, transparent, ${color}22, transparent)`,
46
+ }}
47
+ />
48
+ );
49
+ })}
50
+
51
+ {/* Text container */}
52
+ <div
53
+ style={{
54
+ display: 'flex',
55
+ flexWrap: 'wrap',
56
+ justifyContent: 'center',
57
+ maxWidth: width * 0.9,
58
+ gap: '0 0.05em',
59
+ }}
60
+ >
61
+ {characters.map((char, i) => {
62
+ const charFrame = frame - i * charDelay;
63
+
64
+ // Character animations
65
+ const y = interpolate(charFrame, [0, 15], [100, 0], {
66
+ extrapolateLeft: 'clamp',
67
+ extrapolateRight: 'clamp',
68
+ });
69
+
70
+ const opacity = interpolate(charFrame, [0, 10], [0, 1], {
71
+ extrapolateLeft: 'clamp',
72
+ extrapolateRight: 'clamp',
73
+ });
74
+
75
+ const scale = interpolate(charFrame, [0, 10, 15], [0.5, 1.2, 1], {
76
+ extrapolateLeft: 'clamp',
77
+ extrapolateRight: 'clamp',
78
+ });
79
+
80
+ const rotate = interpolate(charFrame, [0, 15], [-20, 0], {
81
+ extrapolateLeft: 'clamp',
82
+ extrapolateRight: 'clamp',
83
+ });
84
+
85
+ // Glow effect
86
+ const glowIntensity = interpolate(charFrame, [10, 20, 30], [0, 1, 0], {
87
+ extrapolateLeft: 'clamp',
88
+ extrapolateRight: 'clamp',
89
+ });
90
+
91
+ if (char === ' ') {
92
+ return <span key={i} style={{ width: fontSize * 0.3 }} />;
93
+ }
94
+
95
+ return (
96
+ <span
97
+ key={i}
98
+ style={{
99
+ display: 'inline-block',
100
+ fontSize,
101
+ fontWeight: 700,
102
+ color,
103
+ transform: `translateY(${y}px) scale(${scale}) rotate(${rotate}deg)`,
104
+ opacity,
105
+ textShadow: glowIntensity > 0
106
+ ? `0 0 ${20 * glowIntensity}px ${color}, 0 0 ${40 * glowIntensity}px ${color}`
107
+ : 'none',
108
+ fontFamily: 'system-ui, -apple-system, sans-serif',
109
+ }}
110
+ >
111
+ {char}
112
+ </span>
113
+ );
114
+ })}
115
+ </div>
116
+
117
+ {/* Scan line effect */}
118
+ <div
119
+ style={{
120
+ position: 'absolute',
121
+ left: 0,
122
+ right: 0,
123
+ height: 4,
124
+ background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
125
+ top: `${interpolate(frame, [0, 60], [-10, 110], { extrapolateRight: 'clamp' })}%`,
126
+ opacity: 0.5,
127
+ filter: 'blur(2px)',
128
+ }}
129
+ />
130
+ </AbsoluteFill>
131
+ );
132
+ }
133
+
134
+ export default TextReveal;
@@ -0,0 +1,154 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import type { Template } from '@codellyson/framely';
3
+ import { PropsEditor } from '../PropsEditor.tsx';
4
+
5
+ export interface UseTemplateDialogProps {
6
+ open: boolean;
7
+ template: Template | null;
8
+ onClose: () => void;
9
+ onConfirm: (customId: string, customProps: Record<string, unknown>) => void;
10
+ }
11
+
12
+ /**
13
+ * Dialog for confirming template usage and setting custom composition ID
14
+ */
15
+ export function UseTemplateDialog({
16
+ open,
17
+ template,
18
+ onClose,
19
+ onConfirm,
20
+ }: UseTemplateDialogProps) {
21
+ const [customId, setCustomId] = useState('');
22
+ const [customProps, setCustomProps] = useState<Record<string, unknown>>({});
23
+ const [error, setError] = useState<string | null>(null);
24
+ const inputRef = useRef<HTMLInputElement>(null);
25
+
26
+ // Initialize customId and props when template changes
27
+ useEffect(() => {
28
+ if (template) {
29
+ setCustomId(`${template.id}-copy`);
30
+ setCustomProps({ ...template.defaultProps });
31
+ setError(null);
32
+ }
33
+ }, [template]);
34
+
35
+ // Focus input when dialog opens
36
+ useEffect(() => {
37
+ if (open && inputRef.current) {
38
+ inputRef.current.focus();
39
+ inputRef.current.select();
40
+ }
41
+ }, [open]);
42
+
43
+ // Escape key handler
44
+ useEffect(() => {
45
+ if (!open) return;
46
+ const handleKeyDown = (e: KeyboardEvent) => {
47
+ if (e.key === 'Escape') onClose();
48
+ };
49
+ document.addEventListener('keydown', handleKeyDown);
50
+ return () => document.removeEventListener('keydown', handleKeyDown);
51
+ }, [open, onClose]);
52
+
53
+ if (!open || !template) return null;
54
+
55
+ const handleSubmit = (e: React.FormEvent) => {
56
+ e.preventDefault();
57
+
58
+ // Validate ID
59
+ const trimmedId = customId.trim();
60
+ if (!trimmedId) {
61
+ setError('Composition ID is required');
62
+ return;
63
+ }
64
+ if (!/^[a-z0-9-]+$/i.test(trimmedId)) {
65
+ setError('ID can only contain letters, numbers, and hyphens');
66
+ return;
67
+ }
68
+
69
+ onConfirm(trimmedId, customProps);
70
+ };
71
+
72
+ return (
73
+ <div
74
+ className="template-preview-overlay"
75
+ role="dialog"
76
+ aria-modal="true"
77
+ aria-labelledby="use-dialog-title"
78
+ onClick={(e) => {
79
+ if (e.target === e.currentTarget) onClose();
80
+ }}
81
+ >
82
+ <div className="use-template-dialog">
83
+ {/* Header */}
84
+ <div className="template-preview-header">
85
+ <h2 id="use-dialog-title">Use Template</h2>
86
+ <button
87
+ type="button"
88
+ onClick={onClose}
89
+ aria-label="Close"
90
+ className="template-dialog-close"
91
+ >
92
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
93
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
94
+ </svg>
95
+ </button>
96
+ </div>
97
+
98
+ {/* Content */}
99
+ <form onSubmit={handleSubmit} className="use-template-content">
100
+ <p className="use-template-intro">
101
+ Add <strong>{template.name}</strong> as a new composition in your project.
102
+ </p>
103
+
104
+ <div className="use-template-form-group">
105
+ <label htmlFor="composition-id">Composition ID</label>
106
+ <input
107
+ ref={inputRef}
108
+ id="composition-id"
109
+ type="text"
110
+ value={customId}
111
+ onChange={(e) => {
112
+ setCustomId(e.target.value);
113
+ setError(null);
114
+ }}
115
+ placeholder="my-custom-video"
116
+ className={error ? 'has-error' : ''}
117
+ />
118
+ {error && <span className="use-template-error">{error}</span>}
119
+ <span className="use-template-hint">
120
+ This ID will be used to reference the composition in your code
121
+ </span>
122
+ </div>
123
+
124
+ {/* Props Editor */}
125
+ {Object.keys(template.defaultProps).length > 0 && (
126
+ <div className="use-template-props">
127
+ <h4>Customize Properties</h4>
128
+ <PropsEditor
129
+ defaultProps={template.defaultProps}
130
+ onChange={setCustomProps}
131
+ />
132
+ </div>
133
+ )}
134
+
135
+ {/* Footer */}
136
+ <div className="template-preview-footer">
137
+ <button type="button" onClick={onClose} className="template-btn-secondary">
138
+ Cancel
139
+ </button>
140
+ <button
141
+ type="submit"
142
+ className="template-btn-primary"
143
+ disabled={!customId.trim()}
144
+ >
145
+ Add to Project
146
+ </button>
147
+ </div>
148
+ </form>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ export default UseTemplateDialog;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Templates Index
3
+ *
4
+ * Export all template components and provide a registry for the marketplace.
5
+ */
6
+
7
+ import type { ComponentType } from 'react';
8
+ import { SocialIntro } from './SocialIntro';
9
+ import { SubscribeAnimation } from './SubscribeAnimation';
10
+ import { LowerThird } from './LowerThird';
11
+ import { TextReveal } from './TextReveal';
12
+ import { AnimatedGradient } from './AnimatedGradient';
13
+ import { ProductShowcase } from './ProductShowcase';
14
+ import { InstagramStory } from './InstagramStory';
15
+ import { SlideTransition } from './SlideTransition';
16
+
17
+ // Template component registry - maps template IDs to their React components
18
+ export const templateComponents: Record<string, ComponentType<any>> = {
19
+ 'social-intro-1': SocialIntro,
20
+ 'youtube-subscribe': SubscribeAnimation,
21
+ 'lower-third-1': LowerThird,
22
+ 'text-reveal-1': TextReveal,
23
+ 'gradient-bg-1': AnimatedGradient,
24
+ 'promo-slide-1': ProductShowcase,
25
+ 'instagram-story': InstagramStory,
26
+ 'presentation-1': SlideTransition,
27
+ };
28
+
29
+ // Get a template component by ID
30
+ export function getTemplateComponent(templateId: string): ComponentType<any> | null {
31
+ return templateComponents[templateId] || null;
32
+ }
33
+
34
+ // Export individual components
35
+ export { SocialIntro } from './SocialIntro';
36
+ export { SubscribeAnimation } from './SubscribeAnimation';
37
+ export { LowerThird } from './LowerThird';
38
+ export { TextReveal } from './TextReveal';
39
+ export { AnimatedGradient } from './AnimatedGradient';
40
+ export { ProductShowcase } from './ProductShowcase';
41
+ export { InstagramStory } from './InstagramStory';
42
+ export { SlideTransition } from './SlideTransition';
43
+
44
+ // Marketplace UI
45
+ export { TemplatesMarketplace } from './TemplatesMarketplace';
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Browser Utilities
3
+ *
4
+ * Manages headless browser instances for rendering.
5
+ */
6
+
7
+ import { chromium } from 'playwright';
8
+
9
+ /**
10
+ * Default browser launch arguments for rendering.
11
+ *
12
+ * Security-related flags:
13
+ * - `--no-sandbox` / `--disable-setuid-sandbox`: Required in Docker/CI environments
14
+ * where Chrome's sandbox cannot be set up. The browser is only loading trusted
15
+ * local content (localhost), so the sandbox provides minimal additional protection.
16
+ * - `--disable-web-security`: Allows the renderer to load cross-origin resources
17
+ * (fonts, images, video) without CORS restrictions. Necessary because compositions
18
+ * may reference assets from CDNs or local file:// paths during rendering.
19
+ * - `--disable-features=IsolateOrigins` / `--disable-site-isolation-trials`:
20
+ * Reduces memory overhead by not isolating each origin into its own process.
21
+ * Only one origin (localhost) is loaded during rendering.
22
+ *
23
+ * Performance-related flags:
24
+ * - `--disable-gpu`: Headless rendering doesn't benefit from GPU compositing
25
+ * and GPU initialization can cause issues in server environments.
26
+ * - `--disable-dev-shm-usage`: Uses /tmp instead of /dev/shm which may be too
27
+ * small in Docker containers, preventing crashes on large pages.
28
+ * - `--disable-background-timer-throttling` / `--disable-backgrounding-occluded-windows`
29
+ * / `--disable-renderer-backgrounding`: Prevents Chrome from throttling timers
30
+ * and rendering in background tabs, ensuring consistent frame timing.
31
+ * - `--disable-ipc-flooding-protection`: Allows rapid frame updates without
32
+ * Chrome's IPC rate limiting interfering with the capture loop.
33
+ * - `--autoplay-policy=no-user-gesture-required`: Allows audio/video elements
34
+ * to play automatically for compositions that include media.
35
+ */
36
+ const DEFAULT_ARGS = [
37
+ '--disable-web-security',
38
+ '--disable-features=IsolateOrigins',
39
+ '--disable-site-isolation-trials',
40
+ '--no-sandbox',
41
+ '--disable-setuid-sandbox',
42
+ '--disable-gpu',
43
+ '--disable-dev-shm-usage',
44
+ '--disable-background-timer-throttling',
45
+ '--disable-backgrounding-occluded-windows',
46
+ '--disable-renderer-backgrounding',
47
+ '--disable-ipc-flooding-protection',
48
+ '--autoplay-policy=no-user-gesture-required',
49
+ ];
50
+
51
+ /**
52
+ * Create a new browser instance configured for rendering.
53
+ *
54
+ * @param {object} options
55
+ * @param {number} [options.width=1920] - Viewport width
56
+ * @param {number} [options.height=1080] - Viewport height
57
+ * @param {number} [options.scale=1] - Device scale factor
58
+ * @param {string} [options.executablePath] - Custom browser executable
59
+ * @param {boolean} [options.headless=true] - Run in headless mode
60
+ * @returns {Promise<{ browser: Browser, page: Page }>}
61
+ */
62
+ export async function createBrowser({
63
+ width = 1920,
64
+ height = 1080,
65
+ scale = 1,
66
+ executablePath,
67
+ headless = true,
68
+ } = {}) {
69
+ const launchOptions = {
70
+ args: DEFAULT_ARGS,
71
+ headless,
72
+ };
73
+
74
+ if (executablePath) {
75
+ launchOptions.executablePath = executablePath;
76
+ }
77
+
78
+ const browser = await chromium.launch(launchOptions);
79
+
80
+ const context = await browser.newContext({
81
+ viewport: {
82
+ width: Math.round(width * scale),
83
+ height: Math.round(height * scale),
84
+ },
85
+ deviceScaleFactor: 1,
86
+ });
87
+
88
+ const page = await context.newPage();
89
+
90
+ return { browser, page };
91
+ }
92
+
93
+ /**
94
+ * Close a browser instance.
95
+ *
96
+ * @param {Browser} browser
97
+ */
98
+ export async function closeBrowser(browser) {
99
+ if (browser) {
100
+ try {
101
+ await browser.close();
102
+ } catch {
103
+ // Ignore close errors
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Create multiple browser instances for parallel rendering.
110
+ *
111
+ * @param {number} count - Number of browser instances
112
+ * @param {object} options - Browser options (same as createBrowser)
113
+ * @returns {Promise<Array<{ browser: Browser, page: Page }>>}
114
+ */
115
+ export async function createBrowserPool(count, options = {}) {
116
+ const browsers = await Promise.all(
117
+ Array.from({ length: count }, () => createBrowser(options))
118
+ );
119
+ return browsers;
120
+ }
121
+
122
+ /**
123
+ * Close all browsers in a pool.
124
+ *
125
+ * @param {Array<{ browser: Browser }>} pool
126
+ */
127
+ export async function closeBrowserPool(pool) {
128
+ await Promise.all(pool.map(({ browser }) => closeBrowser(browser)));
129
+ }
130
+
131
+ /**
132
+ * Wait for all pending delay renders to complete.
133
+ *
134
+ * @param {Page} page - Playwright page
135
+ * @param {number} timeout - Timeout in milliseconds
136
+ */
137
+ export async function waitForDelayRenders(page, timeout = 30000) {
138
+ await page.waitForFunction(
139
+ () => {
140
+ const dr = window.__FRAMELY_DELAY_RENDER;
141
+ return !dr || dr.pendingCount === 0;
142
+ },
143
+ { timeout }
144
+ );
145
+ }
146
+
147
+ /** Default timeout for waiting on delayRender and page readiness (ms). */
148
+ export const DEFAULT_TIMEOUT = 30000;
149
+
150
+ /**
151
+ * Set the current frame and wait for render to complete.
152
+ *
153
+ * @param {Page} page - Playwright page
154
+ * @param {number} frame - Frame number to render
155
+ * @param {number} [timeout=30000] - Timeout in ms for delayRender
156
+ */
157
+ export async function setFrame(page, frame, timeout = DEFAULT_TIMEOUT) {
158
+ // Set frame and check delayRender in a single evaluate round-trip
159
+ const hasDelayRender = await page.evaluate((f) => {
160
+ window.__setFrame(f);
161
+ const dr = window.__FRAMELY_DELAY_RENDER;
162
+ return dr && dr.pendingCount > 0;
163
+ }, frame);
164
+
165
+ // Only wait for delayRender if something is actually pending
166
+ if (hasDelayRender) {
167
+ try {
168
+ await page.waitForFunction(
169
+ () => {
170
+ const dr = window.__FRAMELY_DELAY_RENDER;
171
+ return !dr || dr.pendingCount === 0;
172
+ },
173
+ { timeout }
174
+ );
175
+ } catch {
176
+ // Continue even if delayRender check fails
177
+ }
178
+ }
179
+ }
180
+
181
+ export default {
182
+ createBrowser,
183
+ closeBrowser,
184
+ createBrowserPool,
185
+ closeBrowserPool,
186
+ waitForDelayRenders,
187
+ setFrame,
188
+ };