designlang 11.0.2 → 11.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.
- package/bin/design-extract.js +55 -0
- package/package.json +1 -1
- package/src/formatters/storybook.js +225 -0
- package/src/replay.js +131 -0
package/bin/design-extract.js
CHANGED
|
@@ -85,6 +85,7 @@ program
|
|
|
85
85
|
.option('--insecure', 'ignore HTTPS/SSL certificate errors (self-signed, dev, proxies)')
|
|
86
86
|
.option('--ignore <selectors...>', 'CSS selectors to remove before extraction')
|
|
87
87
|
.option('--ignore-widgets', 'Also ignore a curated list of third-party widgets (Intercom, Drift, HubSpot chat, cookie banners, reCAPTCHA, etc.) See `designlang widgets`.')
|
|
88
|
+
.option('--storybook', 'Emit a runnable Storybook project (stories/, .storybook/, package.json) alongside the extraction')
|
|
88
89
|
.option('--selector <css>', 'only extract design from elements matching this CSS selector (e.g. ".pricing-card")')
|
|
89
90
|
.option('--system-chrome', 'use the system Chrome install instead of the bundled Chromium (skips the 150MB Playwright download)')
|
|
90
91
|
.option('--tokens-legacy', 'Emit pre-v7 flat token JSON (backward compat)')
|
|
@@ -473,6 +474,20 @@ program
|
|
|
473
474
|
}
|
|
474
475
|
}
|
|
475
476
|
|
|
477
|
+
// Storybook project (opt-in via --storybook)
|
|
478
|
+
if (merged.storybook && Array.isArray(design.componentAnatomy) && design.componentAnatomy.length > 0) {
|
|
479
|
+
const { formatStorybook } = await import('../src/formatters/storybook.js');
|
|
480
|
+
const sbFiles = formatStorybook(design);
|
|
481
|
+
const sbDir = join(outDir, `${prefix}-storybook`);
|
|
482
|
+
mkdirSync(sbDir, { recursive: true });
|
|
483
|
+
for (const [rel, content] of Object.entries(sbFiles)) {
|
|
484
|
+
const p = join(sbDir, rel);
|
|
485
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
486
|
+
writeFileSync(p, content, 'utf-8');
|
|
487
|
+
platformFiles.push({ path: p, label: `Storybook (${rel})` });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
476
491
|
// Save to history
|
|
477
492
|
if (opts.history !== false) {
|
|
478
493
|
const histInfo = saveSnapshot(design);
|
|
@@ -1062,6 +1077,46 @@ program
|
|
|
1062
1077
|
}
|
|
1063
1078
|
});
|
|
1064
1079
|
|
|
1080
|
+
// ── Replay — record a short WebM of motion from a URL ─────
|
|
1081
|
+
program
|
|
1082
|
+
.command('replay <url>')
|
|
1083
|
+
.description('Record a short WebM clip of a site\'s motion (scroll + hover). Optional MP4 if ffmpeg is on PATH.')
|
|
1084
|
+
.option('-o, --out <dir>', 'output directory', './design-extract-output')
|
|
1085
|
+
.option('-n, --name <name>', 'output file prefix', 'motion-replay')
|
|
1086
|
+
.option('-d, --duration <s>', 'duration in seconds (2-15)', parseInt, 5)
|
|
1087
|
+
.option('-w, --width <px>', 'viewport width', parseInt, 1280)
|
|
1088
|
+
.option('--height <px>', 'viewport height', parseInt, 800)
|
|
1089
|
+
.option('--mp4', 'also emit an MP4 (requires ffmpeg on PATH)')
|
|
1090
|
+
.action(async (url, opts) => {
|
|
1091
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
1092
|
+
validateUrl(url);
|
|
1093
|
+
const spinner = ora('Recording motion replay...').start();
|
|
1094
|
+
try {
|
|
1095
|
+
const { recordReplay } = await import('../src/replay.js');
|
|
1096
|
+
const r = await recordReplay(url, {
|
|
1097
|
+
out: opts.out,
|
|
1098
|
+
prefix: opts.name,
|
|
1099
|
+
duration: opts.duration,
|
|
1100
|
+
width: opts.width,
|
|
1101
|
+
height: opts.height,
|
|
1102
|
+
mp4: opts.mp4,
|
|
1103
|
+
});
|
|
1104
|
+
if (!r.webm) {
|
|
1105
|
+
spinner.fail('No video was produced. The browser may have blocked recording; try a different URL.');
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
1108
|
+
spinner.succeed(`Replay captured (${r.duration}s)`);
|
|
1109
|
+
console.log('');
|
|
1110
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(r.webm)} — WebM`);
|
|
1111
|
+
if (r.mp4) console.log(` ${chalk.green('✓')} ${chalk.cyan(r.mp4)} — MP4`);
|
|
1112
|
+
else if (opts.mp4) console.log(` ${chalk.gray('note: ffmpeg not found on PATH; MP4 skipped')}`);
|
|
1113
|
+
console.log('');
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
spinner.fail(err.message);
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1065
1120
|
// ── Widgets — print the curated third-party widget ignore list ─
|
|
1066
1121
|
program
|
|
1067
1122
|
.command('widgets')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "11.0
|
|
3
|
+
"version": "11.1.0",
|
|
4
4
|
"description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Storybook emitter — turns extracted componentAnatomy + design tokens into
|
|
2
|
+
// a runnable CSF3 Storybook project. Writes stories/*, .storybook/*, and a
|
|
3
|
+
// README showing the install + run steps. Zero Storybook dependency in
|
|
4
|
+
// designlang itself; the emitted project installs its own.
|
|
5
|
+
//
|
|
6
|
+
// Consumer wiring (bin):
|
|
7
|
+
// if (merged.storybook && (design.componentAnatomy || []).length) {
|
|
8
|
+
// const { formatStorybook } = await import('../src/formatters/storybook.js');
|
|
9
|
+
// const files = formatStorybook(design);
|
|
10
|
+
// for (const [rel, content] of Object.entries(files)) {
|
|
11
|
+
// writeFileSync(join(outDir, 'storybook', rel), content);
|
|
12
|
+
// }
|
|
13
|
+
// }
|
|
14
|
+
|
|
15
|
+
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
16
|
+
|
|
17
|
+
function htmlTagFor(kind) {
|
|
18
|
+
return kind === 'input' ? 'input'
|
|
19
|
+
: kind === 'link' ? 'a'
|
|
20
|
+
: kind === 'card' ? 'div'
|
|
21
|
+
: 'button';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function storyFor(anatomy, tokenVars) {
|
|
25
|
+
const Name = cap(anatomy.kind);
|
|
26
|
+
const variants = anatomy.props.variant.length ? anatomy.props.variant : ['default'];
|
|
27
|
+
const sizes = anatomy.props.size.length ? anatomy.props.size : ['md'];
|
|
28
|
+
const tag = htmlTagFor(anatomy.kind);
|
|
29
|
+
const hasIcon = !!anatomy.slots?.icon;
|
|
30
|
+
const hasBadge = !!anatomy.slots?.badge;
|
|
31
|
+
|
|
32
|
+
const sampleLabel = anatomy.kind === 'card' ? 'Card content'
|
|
33
|
+
: anatomy.kind === 'input' ? ''
|
|
34
|
+
: anatomy.kind === 'link' ? 'Read more'
|
|
35
|
+
: 'Button';
|
|
36
|
+
|
|
37
|
+
// Render via inline React to keep the emitted project dependency-light.
|
|
38
|
+
const render = `(args) => {
|
|
39
|
+
const style = {
|
|
40
|
+
fontFamily: 'var(--font-sans, inherit)',
|
|
41
|
+
padding: args.size === 'sm' ? '6px 12px' : args.size === 'lg' ? '14px 22px' : '10px 16px',
|
|
42
|
+
borderRadius: 'var(--radius, 8px)',
|
|
43
|
+
background: args.variant === 'secondary' ? 'transparent'
|
|
44
|
+
: args.variant === 'outline' ? 'transparent'
|
|
45
|
+
: 'var(--color-primary, #3b82f6)',
|
|
46
|
+
color: args.variant === 'secondary' || args.variant === 'outline' ? 'var(--color-foreground, #111)' : '#fff',
|
|
47
|
+
border: args.variant === 'outline' ? '1px solid var(--color-foreground, #111)' : 'none',
|
|
48
|
+
fontWeight: 500,
|
|
49
|
+
cursor: 'pointer',
|
|
50
|
+
};
|
|
51
|
+
return React.createElement('${tag}', { style, 'data-variant': args.variant, 'data-size': args.size },
|
|
52
|
+
'${sampleLabel}'${hasIcon ? ", React.createElement('span', { style: { marginLeft: 8 } }, '→')" : ''}${hasBadge ? ", React.createElement('span', { style: { marginLeft: 6, padding: '2px 6px', background: '#f59e0b', borderRadius: 99, color: '#fff', fontSize: 11 } }, '3')" : ''}
|
|
53
|
+
);
|
|
54
|
+
}`;
|
|
55
|
+
|
|
56
|
+
return `import * as React from 'react';
|
|
57
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
58
|
+
|
|
59
|
+
// Extracted from a live site by \`designlang\`. No runtime library —
|
|
60
|
+
// these stories render inline to stay dependency-free.
|
|
61
|
+
const ${Name}: React.FC<{ variant?: string; size?: string }> = ${render};
|
|
62
|
+
|
|
63
|
+
const meta: Meta<typeof ${Name}> = {
|
|
64
|
+
title: 'Extracted/${Name}',
|
|
65
|
+
component: ${Name},
|
|
66
|
+
tags: ['autodocs'],
|
|
67
|
+
argTypes: {
|
|
68
|
+
variant: { control: 'select', options: [${variants.map(v => `'${v}'`).join(', ')}] },
|
|
69
|
+
size: { control: 'select', options: [${sizes.map(s => `'${s}'`).join(', ')}] },
|
|
70
|
+
},
|
|
71
|
+
parameters: {
|
|
72
|
+
docs: {
|
|
73
|
+
description: {
|
|
74
|
+
component: '${anatomy.kind} — ${anatomy.totalInstances || 0} instances detected across the page.',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
export default meta;
|
|
80
|
+
|
|
81
|
+
type Story = StoryObj<typeof ${Name}>;
|
|
82
|
+
|
|
83
|
+
${variants.map(v => `export const ${cap(v)}: Story = { args: { variant: '${v}', size: '${sizes[0]}' } };`).join('\n')}
|
|
84
|
+
|
|
85
|
+
export const Sizes: Story = {
|
|
86
|
+
render: () => React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center' } },
|
|
87
|
+
${sizes.map(s => `React.createElement(${Name}, { key: '${s}', variant: '${variants[0]}', size: '${s}' })`).join(',\n ')}
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokensCss(design) {
|
|
94
|
+
const colors = design.colors || {};
|
|
95
|
+
const primary = colors.primary?.hex || '#3b82f6';
|
|
96
|
+
const secondary = colors.secondary?.hex || '#8b5cf6';
|
|
97
|
+
const accent = colors.accent?.hex || '#f59e0b';
|
|
98
|
+
const bg = colors.backgrounds?.[0] || '#ffffff';
|
|
99
|
+
const fg = colors.text?.[0] || '#171717';
|
|
100
|
+
const radii = design.borders?.radii || [];
|
|
101
|
+
const radius = radii.find(r => r.label === 'md')?.value ?? 8;
|
|
102
|
+
const shadow = design.shadows?.values?.find(s => s.label === 'md')?.raw || '0 4px 6px rgba(0,0,0,0.1)';
|
|
103
|
+
const font = design.typography?.families?.[0]?.name || 'Inter';
|
|
104
|
+
return `:root {
|
|
105
|
+
--color-primary: ${primary};
|
|
106
|
+
--color-secondary: ${secondary};
|
|
107
|
+
--color-accent: ${accent};
|
|
108
|
+
--color-background: ${bg};
|
|
109
|
+
--color-foreground: ${fg};
|
|
110
|
+
--radius: ${radius}px;
|
|
111
|
+
--shadow: ${shadow};
|
|
112
|
+
--font-sans: '${font}', system-ui, sans-serif;
|
|
113
|
+
}
|
|
114
|
+
body { background: var(--color-background); color: var(--color-foreground); font-family: var(--font-sans); }
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatStorybook(design) {
|
|
119
|
+
const anatomies = design.componentAnatomy || [];
|
|
120
|
+
const files = {};
|
|
121
|
+
|
|
122
|
+
// Story files (one per kind).
|
|
123
|
+
for (const a of anatomies) {
|
|
124
|
+
const Name = cap(a.kind);
|
|
125
|
+
files[`stories/${Name}.stories.tsx`] = storyFor(a, design);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A tokens page so people can see the palette/scale inline.
|
|
129
|
+
files['stories/Tokens.mdx'] = `import { Meta, ColorPalette, ColorItem, Typeset } from '@storybook/blocks';
|
|
130
|
+
|
|
131
|
+
<Meta title="Extracted/Tokens" />
|
|
132
|
+
|
|
133
|
+
# Design tokens
|
|
134
|
+
|
|
135
|
+
Extracted from ${design.meta?.url || 'the target site'} by [designlang](https://github.com/Manavarya09/design-extract).
|
|
136
|
+
|
|
137
|
+
<ColorPalette>
|
|
138
|
+
<ColorItem title="primary" colors={{ primary: '${design.colors?.primary?.hex || '#3b82f6'}' }} />
|
|
139
|
+
<ColorItem title="secondary" colors={{ secondary: '${design.colors?.secondary?.hex || '#8b5cf6'}' }} />
|
|
140
|
+
<ColorItem title="accent" colors={{ accent: '${design.colors?.accent?.hex || '#f59e0b'}' }} />
|
|
141
|
+
<ColorItem title="background" colors={{ background: '${design.colors?.backgrounds?.[0] || '#ffffff'}' }} />
|
|
142
|
+
<ColorItem title="foreground" colors={{ foreground: '${design.colors?.text?.[0] || '#171717'}' }} />
|
|
143
|
+
</ColorPalette>
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
files['stories/tokens.css'] = tokensCss(design);
|
|
147
|
+
|
|
148
|
+
files['.storybook/main.ts'] = `import type { StorybookConfig } from '@storybook/react-vite';
|
|
149
|
+
|
|
150
|
+
const config: StorybookConfig = {
|
|
151
|
+
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(ts|tsx)'],
|
|
152
|
+
addons: ['@storybook/addon-essentials', '@storybook/addon-docs'],
|
|
153
|
+
framework: { name: '@storybook/react-vite', options: {} },
|
|
154
|
+
};
|
|
155
|
+
export default config;
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
files['.storybook/preview.ts'] = `import type { Preview } from '@storybook/react';
|
|
159
|
+
import './../stories/tokens.css';
|
|
160
|
+
|
|
161
|
+
const preview: Preview = {
|
|
162
|
+
parameters: {
|
|
163
|
+
backgrounds: { default: 'paper' },
|
|
164
|
+
controls: { matchers: { color: /(background|color)$/i } },
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
export default preview;
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
files['package.json'] = JSON.stringify({
|
|
171
|
+
name: `${(design.meta?.title || 'extracted').toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 40) || 'extracted'}-storybook`,
|
|
172
|
+
private: true,
|
|
173
|
+
version: '0.1.0',
|
|
174
|
+
type: 'module',
|
|
175
|
+
scripts: {
|
|
176
|
+
storybook: 'storybook dev -p 6006',
|
|
177
|
+
'build-storybook': 'storybook build',
|
|
178
|
+
},
|
|
179
|
+
dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0' },
|
|
180
|
+
devDependencies: {
|
|
181
|
+
'@storybook/addon-essentials': '^8.0.0',
|
|
182
|
+
'@storybook/addon-docs': '^8.0.0',
|
|
183
|
+
'@storybook/blocks': '^8.0.0',
|
|
184
|
+
'@storybook/react': '^8.0.0',
|
|
185
|
+
'@storybook/react-vite': '^8.0.0',
|
|
186
|
+
'@types/react': '^19.0.0',
|
|
187
|
+
'@types/react-dom': '^19.0.0',
|
|
188
|
+
storybook: '^8.0.0',
|
|
189
|
+
typescript: '^5.0.0',
|
|
190
|
+
vite: '^5.0.0',
|
|
191
|
+
},
|
|
192
|
+
}, null, 2);
|
|
193
|
+
|
|
194
|
+
files['tsconfig.json'] = JSON.stringify({
|
|
195
|
+
compilerOptions: {
|
|
196
|
+
target: 'ES2020',
|
|
197
|
+
module: 'ESNext',
|
|
198
|
+
moduleResolution: 'Bundler',
|
|
199
|
+
jsx: 'react-jsx',
|
|
200
|
+
strict: true,
|
|
201
|
+
esModuleInterop: true,
|
|
202
|
+
skipLibCheck: true,
|
|
203
|
+
},
|
|
204
|
+
include: ['stories/**/*', '.storybook/**/*'],
|
|
205
|
+
}, null, 2);
|
|
206
|
+
|
|
207
|
+
files['README.md'] = `# ${design.meta?.title || 'Extracted'} · Storybook
|
|
208
|
+
|
|
209
|
+
Auto-generated by \`designlang <url> --storybook\`.
|
|
210
|
+
|
|
211
|
+
## Stories
|
|
212
|
+
${anatomies.map(a => `- **${cap(a.kind)}** — ${a.props.variant.length || 1} variant(s), ${a.props.size.length || 1} size(s), ${a.totalInstances || 0} detected`).join('\n') || '_No anatomy detected on the source page._'}
|
|
213
|
+
|
|
214
|
+
## Run
|
|
215
|
+
|
|
216
|
+
\`\`\`
|
|
217
|
+
npm install
|
|
218
|
+
npm run storybook
|
|
219
|
+
\`\`\`
|
|
220
|
+
|
|
221
|
+
Opens at http://localhost:6006.
|
|
222
|
+
`;
|
|
223
|
+
|
|
224
|
+
return files;
|
|
225
|
+
}
|
package/src/replay.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Motion replay — records a short WebM of a site with scripted
|
|
2
|
+
// interactions (hover/scroll) so the captured motion actually fires.
|
|
3
|
+
// Zero new deps: Playwright ships video recording out of the box.
|
|
4
|
+
//
|
|
5
|
+
// Output: <prefix>-motion.webm (+ optional MP4 if ffmpeg is on PATH)
|
|
6
|
+
//
|
|
7
|
+
// Usage: designlang replay <url> [--duration 5] [--out dir]
|
|
8
|
+
|
|
9
|
+
import { chromium } from 'playwright';
|
|
10
|
+
import { mkdirSync, existsSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from 'fs';
|
|
11
|
+
import { resolve, join } from 'path';
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
|
|
14
|
+
async function ensureFfmpeg() {
|
|
15
|
+
try {
|
|
16
|
+
const r = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
17
|
+
return r.status === 0;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toMp4(webmPath) {
|
|
22
|
+
const mp4Path = webmPath.replace(/\.webm$/, '.mp4');
|
|
23
|
+
const r = spawnSync('ffmpeg', ['-y', '-i', webmPath, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4Path], { stdio: 'ignore' });
|
|
24
|
+
return r.status === 0 ? mp4Path : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function recordReplay(url, opts = {}) {
|
|
28
|
+
const outDir = resolve(opts.out || './design-extract-output');
|
|
29
|
+
mkdirSync(outDir, { recursive: true });
|
|
30
|
+
const prefix = opts.prefix || 'motion-replay';
|
|
31
|
+
const duration = Math.max(2, Math.min(15, parseInt(opts.duration) || 5));
|
|
32
|
+
const width = parseInt(opts.width) || 1280;
|
|
33
|
+
const height = parseInt(opts.height) || 800;
|
|
34
|
+
|
|
35
|
+
const videoDir = join(outDir, `.playwright-video-${Date.now()}`);
|
|
36
|
+
mkdirSync(videoDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const browser = await chromium.launch({ headless: true });
|
|
39
|
+
const context = await browser.newContext({
|
|
40
|
+
viewport: { width, height },
|
|
41
|
+
recordVideo: { dir: videoDir, size: { width, height } },
|
|
42
|
+
reducedMotion: 'no-preference',
|
|
43
|
+
});
|
|
44
|
+
const page = await context.newPage();
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 });
|
|
48
|
+
} catch {
|
|
49
|
+
try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Scripted motion pass: slow scroll → hover first CTA → scroll back.
|
|
53
|
+
const frameMs = 50;
|
|
54
|
+
const steps = Math.floor((duration * 1000) / frameMs);
|
|
55
|
+
for (let i = 0; i < steps; i++) {
|
|
56
|
+
const progress = i / steps;
|
|
57
|
+
// Slow smooth scroll across the page
|
|
58
|
+
await page.evaluate((p) => {
|
|
59
|
+
const max = Math.max(document.body.scrollHeight - window.innerHeight, 0);
|
|
60
|
+
window.scrollTo({ top: max * p, behavior: 'auto' });
|
|
61
|
+
}, progress).catch(() => {});
|
|
62
|
+
// Partway through, hover a button to trigger transition states.
|
|
63
|
+
if (i === Math.floor(steps / 2)) {
|
|
64
|
+
try {
|
|
65
|
+
const btn = await page.$('button, [role="button"], a.btn, .button, [class*="btn"]');
|
|
66
|
+
if (btn) await btn.hover({ timeout: 1000 }).catch(() => {});
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
await page.waitForTimeout(frameMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const videoPromise = page.video();
|
|
73
|
+
await context.close();
|
|
74
|
+
await browser.close();
|
|
75
|
+
|
|
76
|
+
// Move the captured WebM out of the Playwright temp dir.
|
|
77
|
+
const finalWebm = join(outDir, `${prefix}.webm`);
|
|
78
|
+
try {
|
|
79
|
+
const video = await videoPromise;
|
|
80
|
+
if (video && typeof video.path === 'function') {
|
|
81
|
+
const src = await video.path();
|
|
82
|
+
if (src && existsSync(src)) {
|
|
83
|
+
renameSync(src, finalWebm);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
// Fallback: grab whatever .webm Playwright wrote to the dir.
|
|
87
|
+
const produced = readdirSync(videoDir).filter(f => f.endsWith('.webm'))
|
|
88
|
+
.map(f => ({ f, t: statSync(join(videoDir, f)).mtimeMs }))
|
|
89
|
+
.sort((a, b) => b.t - a.t)[0];
|
|
90
|
+
if (produced) renameSync(join(videoDir, produced.f), finalWebm);
|
|
91
|
+
}
|
|
92
|
+
} catch { /* no video — fall through with null result */ }
|
|
93
|
+
|
|
94
|
+
// Clean the scratch dir.
|
|
95
|
+
try {
|
|
96
|
+
for (const f of readdirSync(videoDir)) unlinkSync(join(videoDir, f));
|
|
97
|
+
rmdirSync(videoDir);
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
const result = {
|
|
101
|
+
url,
|
|
102
|
+
webm: existsSync(finalWebm) ? finalWebm : null,
|
|
103
|
+
mp4: null,
|
|
104
|
+
duration,
|
|
105
|
+
width,
|
|
106
|
+
height,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (result.webm && opts.mp4 && await ensureFfmpeg()) {
|
|
110
|
+
const mp4 = toMp4(result.webm);
|
|
111
|
+
if (mp4) result.mp4 = mp4;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Tiny snippet that can be spliced into the existing HTML preview to embed
|
|
118
|
+
// the replay next to the static screenshot. Consumer decides where to place.
|
|
119
|
+
export function replayEmbedHtml(result) {
|
|
120
|
+
if (!result?.webm) return '';
|
|
121
|
+
const src = result.webm.split(/[\\/]/).pop();
|
|
122
|
+
return `<figure class="motion-replay" style="margin:24px 0">
|
|
123
|
+
<video autoplay loop muted playsinline style="width:100%;border:1px solid #0a0908">
|
|
124
|
+
<source src="${src}" type="video/webm" />
|
|
125
|
+
${result.mp4 ? `<source src="${result.mp4.split(/[\\/]/).pop()}" type="video/mp4" />` : ''}
|
|
126
|
+
</video>
|
|
127
|
+
<figcaption style="font-family:var(--font-mono, monospace);font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#403c34;margin-top:8px">
|
|
128
|
+
motion replay · ${result.duration}s · ${result.width}×${result.height}
|
|
129
|
+
</figcaption>
|
|
130
|
+
</figure>`;
|
|
131
|
+
}
|