mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
/**
|
|
* Generates Vite test pages from PageEntry definitions.
|
|
*
|
|
* Reads the media type configs and page arrays, then writes .ts/.tsx + .html
|
|
* files to `apps/vite/src/pages/`. Special pages (ejected skins, captions) are
|
|
* hand-written and not generated.
|
|
*
|
|
* Run: `pnpm --dir apps/e2e generate-pages`
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const OUT_DIR = resolve(__dirname, '../apps/vite/src/pages');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Media type config — maps media element name to its import + attributes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface MediaTypeConfig {
|
|
/** Custom element tag (e.g. 'hlsjs-video', 'mux-video') */
|
|
element: string;
|
|
/** Side-effect imports to register the element */
|
|
imports: string[];
|
|
/** HTML attributes on the media element */
|
|
attrs: string;
|
|
/** Whether a storyboard track should be included */
|
|
hasStoryboard: boolean;
|
|
/** Whether a poster image should be included */
|
|
hasPoster: boolean;
|
|
/** Whether this is an audio element */
|
|
isAudio: boolean;
|
|
}
|
|
|
|
const MEDIA_TYPES: Record<string, MediaTypeConfig> = {
|
|
video: {
|
|
element: 'video',
|
|
imports: [],
|
|
attrs: 'playsinline crossorigin="anonymous"',
|
|
hasStoryboard: true,
|
|
hasPoster: true,
|
|
isAudio: false,
|
|
},
|
|
'hlsjs-video': {
|
|
element: 'hlsjs-video',
|
|
imports: ['@videojs/html/media/hlsjs-video'],
|
|
attrs: 'playsinline crossorigin="anonymous"',
|
|
hasStoryboard: true,
|
|
hasPoster: true,
|
|
isAudio: false,
|
|
},
|
|
'simple-hls-video': {
|
|
element: 'simple-hls-video',
|
|
imports: ['@videojs/html/media/simple-hls-video'],
|
|
attrs: 'playsinline crossorigin="anonymous" preload="metadata"',
|
|
hasStoryboard: true,
|
|
hasPoster: true,
|
|
isAudio: false,
|
|
},
|
|
'native-hls-video': {
|
|
element: 'native-hls-video',
|
|
imports: ['@videojs/html/media/native-hls-video'],
|
|
attrs: 'playsinline crossorigin="anonymous"',
|
|
hasStoryboard: true,
|
|
hasPoster: true,
|
|
isAudio: false,
|
|
},
|
|
'mux-video': {
|
|
element: 'mux-video',
|
|
imports: ['@videojs/html/media/mux-video'],
|
|
attrs: 'playsinline crossorigin="anonymous"',
|
|
hasStoryboard: true,
|
|
hasPoster: true,
|
|
isAudio: false,
|
|
},
|
|
'dash-video': {
|
|
element: 'dash-video',
|
|
imports: ['@videojs/html/media/dash-video'],
|
|
attrs: 'playsinline',
|
|
hasStoryboard: false,
|
|
hasPoster: false,
|
|
isAudio: false,
|
|
},
|
|
audio: {
|
|
element: 'audio',
|
|
imports: [],
|
|
attrs: '',
|
|
hasStoryboard: false,
|
|
hasPoster: false,
|
|
isAudio: true,
|
|
},
|
|
'mux-audio': {
|
|
element: 'mux-audio',
|
|
imports: ['@videojs/html/media/mux-audio'],
|
|
attrs: 'crossorigin="anonymous"',
|
|
hasStoryboard: false,
|
|
hasPoster: false,
|
|
isAudio: true,
|
|
},
|
|
};
|
|
|
|
// React component names for media elements
|
|
const REACT_MEDIA: Record<string, { component: string; importPath: string }> = {
|
|
video: { component: 'Video', importPath: '@videojs/react/video' },
|
|
'hlsjs-video': { component: 'HlsJsVideo', importPath: '@videojs/react/media/hlsjs-video' },
|
|
audio: { component: 'Audio', importPath: '@videojs/react/audio' },
|
|
};
|
|
|
|
// CDN import paths (override standard imports)
|
|
const CDN_IMPORTS: Record<string, string[]> = {
|
|
video: ['@videojs/html/cdn/video'],
|
|
'hlsjs-video': ['@videojs/html/cdn/video', '@videojs/html/cdn/media/hlsjs-video'],
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page entry type (matches fixtures/media.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PageDef {
|
|
name: string;
|
|
path: string;
|
|
framework: 'html' | 'react';
|
|
media: string;
|
|
resource: string;
|
|
category?: 'cdn' | 'ejected-html' | 'ejected-react' | 'captions';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Templates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function htmlShell(title: string, scriptSrc: string): string {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>${title}</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="./${scriptSrc}"></script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
function htmlVideoPage(config: MediaTypeConfig, resource: string, imports: string[]): string {
|
|
const allImports = [...imports, `import { MEDIA } from '../resources';`].join('\n');
|
|
|
|
const storyboard = config.hasStoryboard
|
|
? `\n <track kind="metadata" label="thumbnails" src="\${MEDIA.${resource}.storyboard}" default />`
|
|
: '';
|
|
|
|
const poster = config.hasPoster
|
|
? `\n <img slot="poster" src="\${MEDIA.${resource}.poster}" alt="Video poster" />`
|
|
: '';
|
|
|
|
const attrs = config.attrs ? ` ${config.attrs}` : '';
|
|
|
|
return `${allImports}
|
|
|
|
const html = String.raw;
|
|
|
|
document.getElementById('root')!.innerHTML = html\`
|
|
<video-player>
|
|
<video-skin style="max-width: 800px; aspect-ratio: 16/9">
|
|
<${config.element} src="\${MEDIA.${resource}.url}"${attrs}>${storyboard}
|
|
</${config.element}>${poster}
|
|
</video-skin>
|
|
</video-player>
|
|
\`;
|
|
`;
|
|
}
|
|
|
|
function htmlAudioPage(config: MediaTypeConfig, resource: string, imports: string[]): string {
|
|
const allImports = [...imports, `import { MEDIA } from '../resources';`].join('\n');
|
|
const attrs = config.attrs ? ` ${config.attrs}` : '';
|
|
|
|
return `${allImports}
|
|
|
|
const html = String.raw;
|
|
|
|
document.getElementById('root')!.innerHTML = html\`
|
|
<div style="max-width: 600px; margin: 0 auto">
|
|
<audio-player>
|
|
<audio-skin>
|
|
<${config.element} src="\${MEDIA.${resource}.url}"${attrs}></${config.element}>
|
|
</audio-skin>
|
|
</audio-player>
|
|
</div>
|
|
\`;
|
|
`;
|
|
}
|
|
|
|
function reactVideoPage(media: string, resource: string, config: MediaTypeConfig): string {
|
|
const reactMedia = REACT_MEDIA[media];
|
|
if (!reactMedia) throw new Error(`No React component mapping for media type: ${media}`);
|
|
|
|
const isDefaultVideo = media === 'video';
|
|
const mediaImport = isDefaultVideo
|
|
? `import { Video, VideoSkin, videoFeatures } from '@videojs/react/video';`
|
|
: `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { VideoSkin, videoFeatures } from '@videojs/react/video';`;
|
|
|
|
const posterProp = config.hasPoster ? ` poster={MEDIA.${resource}.poster}` : '';
|
|
const storyboardTrack = config.hasStoryboard
|
|
? `\n <track kind="metadata" label="thumbnails" src={MEDIA.${resource}.storyboard} default />`
|
|
: '';
|
|
|
|
return `import { createPlayer } from '@videojs/react';
|
|
${mediaImport}
|
|
import '@videojs/react/video/skin.css';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { MEDIA } from '../resources';
|
|
|
|
const Player = createPlayer({ features: videoFeatures });
|
|
|
|
function App() {
|
|
return (
|
|
<Player.Provider>
|
|
<VideoSkin${posterProp} style={{ maxWidth: 800, aspectRatio: '16/9' }}>
|
|
<${reactMedia.component} src={MEDIA.${resource}.url} playsInline crossOrigin="anonymous">${storyboardTrack}
|
|
</${reactMedia.component}>
|
|
</VideoSkin>
|
|
</Player.Provider>
|
|
);
|
|
}
|
|
|
|
createRoot(document.getElementById('root')!).render(<App />);
|
|
`;
|
|
}
|
|
|
|
function reactAudioPage(media: string, resource: string): string {
|
|
const reactMedia = REACT_MEDIA[media];
|
|
if (!reactMedia) throw new Error(`No React component mapping for media type: ${media}`);
|
|
|
|
const isDefaultAudio = media === 'audio';
|
|
const mediaImport = isDefaultAudio
|
|
? `import { Audio, AudioSkin, audioFeatures } from '@videojs/react/audio';`
|
|
: `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { AudioSkin, audioFeatures } from '@videojs/react/audio';`;
|
|
|
|
return `import { createPlayer } from '@videojs/react';
|
|
${mediaImport}
|
|
import '@videojs/react/audio/skin.css';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { MEDIA } from '../resources';
|
|
|
|
const Player = createPlayer({ features: audioFeatures });
|
|
|
|
function App() {
|
|
return (
|
|
<Player.Provider>
|
|
<AudioSkin style={{ maxWidth: 600, margin: '0 auto' }}>
|
|
<${reactMedia.component} src={MEDIA.${resource}.url} />
|
|
</AudioSkin>
|
|
</Player.Provider>
|
|
);
|
|
}
|
|
|
|
createRoot(document.getElementById('root')!).render(<App />);
|
|
`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Special page templates (captions, ejected skins)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function captionsPage(resource: string): string {
|
|
const captionVtt = 'WEBVTT\\n\\n00:00:00.000 --> 00:00:30.000\\nThis is a test caption';
|
|
|
|
return `import '@videojs/html/video/player';
|
|
import '@videojs/html/video/skin';
|
|
import { MEDIA } from '../resources';
|
|
|
|
const html = String.raw;
|
|
|
|
const captionVtt = encodeURIComponent('${captionVtt}');
|
|
|
|
document.getElementById('root')!.innerHTML = html\`
|
|
<video-player>
|
|
<video-skin style="max-width: 800px; aspect-ratio: 16/9">
|
|
<video src="\${MEDIA.${resource}.url}" playsinline crossorigin="anonymous">
|
|
<track kind="metadata" label="thumbnails" src="\${MEDIA.${resource}.storyboard}" default />
|
|
<track kind="subtitles" label="English" srclang="en" src="data:text/vtt,\${captionVtt}" />
|
|
</video>
|
|
<img slot="poster" src="\${MEDIA.${resource}.poster}" alt="Video poster" />
|
|
</video-skin>
|
|
</video-player>
|
|
\`;
|
|
`;
|
|
}
|
|
|
|
function ejectedHtmlPage(): string {
|
|
// Path from pages/ to the site content
|
|
const jsonPath = '../../../../../../site/src/content/ejected-skins.json';
|
|
|
|
return `import '@videojs/html/icons/element';
|
|
import '@videojs/html/video/ui';
|
|
import ejectedSkins from '${jsonPath}';
|
|
|
|
interface EjectedSkinEntry {
|
|
id: string;
|
|
html?: string;
|
|
css?: string;
|
|
}
|
|
|
|
const skin = (ejectedSkins as EjectedSkinEntry[]).find((s) => s.id === 'default-video');
|
|
|
|
if (!skin?.html || !skin?.css) {
|
|
throw new Error('Ejected skin "default-video" not found. Run \\\`pnpm -F site ejected-skins\\\` first.');
|
|
}
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = skin.css;
|
|
document.head.appendChild(style);
|
|
|
|
const playerMatch = skin.html.match(/<video-player>[\\s\\S]*<\\/video-player>/);
|
|
|
|
if (!playerMatch) {
|
|
throw new Error('Could not find <video-player> in ejected HTML output.');
|
|
}
|
|
|
|
const root = document.getElementById('root')!;
|
|
root.innerHTML = \`<div style="max-width: 800px; aspect-ratio: 16/9">\${playerMatch[0]}</div>\`;
|
|
`;
|
|
}
|
|
|
|
function ejectedReactPage(resource: string): string {
|
|
return `import { createRoot } from 'react-dom/client';
|
|
import { VideoPlayer } from '../_generated/ejected-react-video-skin';
|
|
import { MEDIA } from '../resources';
|
|
|
|
function App() {
|
|
return <VideoPlayer src={MEDIA.${resource}.url} poster={MEDIA.${resource}.poster} style={{ maxWidth: 800, aspectRatio: '16/9' }} />;
|
|
}
|
|
|
|
createRoot(document.getElementById('root')!).render(<App />);
|
|
`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page definitions (drive generation from here)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PAGES: PageDef[] = [
|
|
// HTML Video
|
|
{ name: 'HTML Video MP4', path: 'html-video-mp4', framework: 'html', media: 'video', resource: 'mp4' },
|
|
{ name: 'HTML Video HLS', path: 'html-video-hls', framework: 'html', media: 'hlsjs-video', resource: 'hlsTs' },
|
|
{
|
|
name: 'HTML Simple HLS Video fMP4',
|
|
path: 'html-simple-hls-video-fmp4',
|
|
framework: 'html',
|
|
media: 'simple-hls-video',
|
|
resource: 'hlsFmp4',
|
|
},
|
|
{ name: 'HTML DASH Video', path: 'html-dash-video', framework: 'html', media: 'dash-video', resource: 'dash' },
|
|
{
|
|
name: 'HTML Native HLS Video',
|
|
path: 'html-native-hls-video',
|
|
framework: 'html',
|
|
media: 'native-hls-video',
|
|
resource: 'hlsTs',
|
|
},
|
|
{ name: 'HTML Mux Video', path: 'html-mux-video', framework: 'html', media: 'mux-video', resource: 'hlsTs' },
|
|
|
|
// HTML Audio
|
|
{ name: 'HTML Audio MP4', path: 'html-audio-mp4', framework: 'html', media: 'audio', resource: 'mp4' },
|
|
{ name: 'HTML Mux Audio', path: 'html-mux-audio', framework: 'html', media: 'mux-audio', resource: 'hlsTs' },
|
|
|
|
// React Video
|
|
{ name: 'React Video MP4', path: 'react-video-mp4', framework: 'react', media: 'video', resource: 'mp4' },
|
|
{ name: 'React Video HLS', path: 'react-video-hls', framework: 'react', media: 'hlsjs-video', resource: 'hlsTs' },
|
|
|
|
// React Audio
|
|
{ name: 'React Audio MP4', path: 'react-audio-mp4', framework: 'react', media: 'audio', resource: 'mp4' },
|
|
|
|
// CDN
|
|
{ name: 'CDN Video MP4', path: 'cdn-video-mp4', framework: 'html', media: 'video', resource: 'mp4', category: 'cdn' },
|
|
{
|
|
name: 'CDN Video HLS',
|
|
path: 'cdn-video-hls',
|
|
framework: 'html',
|
|
media: 'hlsjs-video',
|
|
resource: 'hlsTs',
|
|
category: 'cdn',
|
|
},
|
|
|
|
// Captions
|
|
{
|
|
name: 'HTML Video Captions',
|
|
path: 'html-video-captions',
|
|
framework: 'html',
|
|
media: 'video',
|
|
resource: 'mp4',
|
|
category: 'captions',
|
|
},
|
|
|
|
// Ejected Skins
|
|
{
|
|
name: 'Ejected HTML Video MP4',
|
|
path: 'ejected-html-video-mp4',
|
|
framework: 'html',
|
|
media: 'video',
|
|
resource: 'mp4',
|
|
category: 'ejected-html',
|
|
},
|
|
{
|
|
name: 'Ejected React Video MP4',
|
|
path: 'ejected-react-video-mp4',
|
|
framework: 'react',
|
|
media: 'video',
|
|
resource: 'mp4',
|
|
category: 'ejected-react',
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getImports(page: PageDef, config: MediaTypeConfig): string[] {
|
|
if (page.category === 'cdn') {
|
|
const cdnImports = CDN_IMPORTS[page.media];
|
|
if (!cdnImports) throw new Error(`No CDN imports for media type: ${page.media}`);
|
|
return cdnImports.map((i) => `import '${i}';`);
|
|
}
|
|
|
|
const playerType = config.isAudio ? 'audio' : 'video';
|
|
const base = [`import '@videojs/html/${playerType}/player';`, `import '@videojs/html/${playerType}/skin';`];
|
|
|
|
for (const imp of config.imports) {
|
|
base.push(`import '${imp}';`);
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
function generatePage(page: PageDef): { ts: string; html: string; ext: string } {
|
|
const config = MEDIA_TYPES[page.media];
|
|
if (!config) throw new Error(`Unknown media type: ${page.media}`);
|
|
|
|
const ext = page.framework === 'react' ? 'tsx' : 'ts';
|
|
|
|
let ts: string;
|
|
|
|
// Special category pages
|
|
if (page.category === 'captions') {
|
|
ts = captionsPage(page.resource);
|
|
} else if (page.category === 'ejected-html') {
|
|
ts = ejectedHtmlPage();
|
|
} else if (page.category === 'ejected-react') {
|
|
ts = ejectedReactPage(page.resource);
|
|
} else if (page.framework === 'react') {
|
|
ts = config.isAudio ? reactAudioPage(page.media, page.resource) : reactVideoPage(page.media, page.resource, config);
|
|
} else {
|
|
const imports = getImports(page, config);
|
|
ts = config.isAudio ? htmlAudioPage(config, page.resource, imports) : htmlVideoPage(config, page.resource, imports);
|
|
}
|
|
|
|
const html = htmlShell(page.name, `${page.path}.${ext}`);
|
|
|
|
return { ts, html, ext };
|
|
}
|
|
|
|
function generateIndexHtml(pages: PageDef[]): string {
|
|
const noCategory = (p: PageDef) => !p.category;
|
|
const htmlVideo = pages.filter((p) => p.framework === 'html' && !MEDIA_TYPES[p.media]?.isAudio && noCategory(p));
|
|
const htmlAudio = pages.filter((p) => p.framework === 'html' && MEDIA_TYPES[p.media]?.isAudio && noCategory(p));
|
|
const reactVideo = pages.filter((p) => p.framework === 'react' && !MEDIA_TYPES[p.media]?.isAudio && noCategory(p));
|
|
const reactAudio = pages.filter((p) => p.framework === 'react' && MEDIA_TYPES[p.media]?.isAudio && noCategory(p));
|
|
const cdn = pages.filter((p) => p.category === 'cdn');
|
|
const ejected = pages.filter((p) => p.category?.startsWith('ejected'));
|
|
const captions = pages.filter((p) => p.category === 'captions');
|
|
|
|
function list(entries: PageDef[]): string {
|
|
return entries.map((p) => ` <li><a href="/pages/${p.path}.html">${p.name}</a></li>`).join('\n');
|
|
}
|
|
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Video.js E2E Test Pages</title>
|
|
</head>
|
|
<body>
|
|
<h1>Video.js E2E Test Pages</h1>
|
|
<nav>
|
|
<h2>HTML (Web Components)</h2>
|
|
<ul>
|
|
${list(htmlVideo)}
|
|
${list(htmlAudio)}
|
|
${list(captions)}
|
|
</ul>
|
|
<h2>React</h2>
|
|
<ul>
|
|
${list(reactVideo)}
|
|
${list(reactAudio)}
|
|
</ul>
|
|
<h2>Ejected Skins</h2>
|
|
<ul>
|
|
${list(ejected)}
|
|
</ul>
|
|
<h2>CDN Bundles</h2>
|
|
<ul>
|
|
${list(cdn)}
|
|
</ul>
|
|
</nav>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log('[generate-pages] Generating Vite test pages...');
|
|
|
|
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
|
|
|
|
let count = 0;
|
|
|
|
for (const page of PAGES) {
|
|
const { ts, html, ext } = generatePage(page);
|
|
|
|
writeFileSync(resolve(OUT_DIR, `${page.path}.${ext}`), ts);
|
|
writeFileSync(resolve(OUT_DIR, `${page.path}.html`), html);
|
|
count++;
|
|
}
|
|
|
|
// Generate index.html in src/ (not pages/)
|
|
const srcDir = resolve(OUT_DIR, '..');
|
|
writeFileSync(resolve(srcDir, 'index.html'), generateIndexHtml(PAGES));
|
|
|
|
console.log(`[generate-pages] Generated ${count} pages + index.html`);
|