import { spawn, type ChildProcess } from 'node:child_process'; import { accessSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { _electron as electron, type ElectronApplication, type Page, } from '@playwright/test'; type ThemeName = 'dark' | 'light'; type ScreenshotSlug = | 'dashboard' | 'm3u-live-groups-two-column' | 'settings' | 'xtream-series-season-open' | 'xtream-vod-details'; type CaptureTarget = { slug: ScreenshotSlug; title: string; kicker: string; }; const workspaceRoot = process.cwd(); const electronMainPath = path.join( workspaceRoot, 'dist/apps/electron-backend/main.js' ); const rendererIndexPath = path.join(workspaceRoot, 'dist/apps/web/index.html'); const xtreamMockOrigin = 'http://localhost:3211'; const outputRoot = path.join( workspaceRoot, 'apps/website/public/blog/v0-20/screenshots' ); const heroOutputPath = path.join( workspaceRoot, 'apps/website/public/blog/v0-20/v0-20-hero.png' ); const viewport = { width: 1280, height: 720 }; const captureTargets: CaptureTarget[] = [ { slug: 'dashboard', title: 'Content-first dashboard', kicker: 'New in v0.20', }, { slug: 'xtream-vod-details', title: 'Richer movie details', kicker: 'Xtream', }, { slug: 'xtream-series-season-open', title: 'Series seasons and episodes', kicker: 'Xtream', }, { slug: 'settings', title: 'Reworked settings', kicker: 'Customization', }, { slug: 'm3u-live-groups-two-column', title: 'Two-column live TV', kicker: 'M3U', }, ]; let capturedM3uPlaylistId: string | undefined; let capturedXtreamPlaylistId: string | undefined; async function main(): Promise { assertBuiltRuntimeExists(); mkdirSync(outputRoot, { recursive: true }); const managedMockServer = await ensureXtreamMockServer(); const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-v020-shots-')); const m3uFixturePath = writeM3uPlaylistFixture(dataDir); let app: ElectronApplication | undefined; try { app = await launchApp(dataDir); const page = await findMainWindow(app); await sizeElectronWindow(app); await waitForAppReady(page); await seedDemoData(page, m3uFixturePath); for (const theme of ['dark', 'light'] as ThemeName[]) { await applyTheme(page, theme); await captureThemeSet(page, theme); } await createHeroImage(page); await verifyExpectedAssets(); } finally { await app?.close().catch(() => undefined); managedMockServer?.kill('SIGTERM'); rmSync(dataDir, { recursive: true, force: true }); } } function assertBuiltRuntimeExists(): void { if (!fileExists(electronMainPath) || !fileExists(rendererIndexPath)) { throw new Error( [ 'Electron E2E runtime is missing.', `Expected ${electronMainPath}`, `Expected ${rendererIndexPath}`, 'Build it first, for example: pnpm nx run electron-backend:build-e2e', ].join('\n') ); } } async function ensureXtreamMockServer(): Promise { if (await isHealthy(`${xtreamMockOrigin}/health`)) { return undefined; } const child = spawn( path.join(workspaceRoot, 'node_modules/.bin/tsx'), ['apps/xtream-mock-server/src/main.ts'], { cwd: workspaceRoot, env: { ...process.env, NODE_ENV: 'development', PORT: '3211', }, stdio: ['ignore', 'pipe', 'pipe'], } ); child.stdout?.on('data', (chunk) => { process.stdout.write(`[xtream-mock] ${chunk}`); }); child.stderr?.on('data', (chunk) => { process.stderr.write(`[xtream-mock] ${chunk}`); }); await waitForHealthy(`${xtreamMockOrigin}/health`, 20_000); return child; } function buildM3uPlaylist(): string { const channels = [ ['Newsroom', 'Aurora Local', 'aurora-local'], ['Newsroom', 'Civic Pulse', 'civic-pulse'], ['Sports', 'Fieldside One', 'fieldside-one'], ['Sports', 'Motion Arena', 'motion-arena'], ['Kids', 'Horizon Kids', 'horizon-kids'], ['Kids', 'Story Lantern', 'story-lantern'], ['Culture', 'Atlas Culture', 'atlas-culture'], ['Culture', 'Night Music', 'night-music'], ]; const stream = `${xtreamMockOrigin}/live/marketing/marketing/52000.m3u8`; const lines = ['#EXTM3U']; channels.forEach(([group, title, slug], index) => { lines.push( `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${xtreamMockOrigin}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}` ); lines.push(stream); }); return `${lines.join('\n')}\n`; } function writeM3uPlaylistFixture(dataDir: string): string { const filePath = path.join(dataDir, 'v020-demo.m3u'); writeFileSync(filePath, buildM3uPlaylist(), 'utf8'); return filePath; } function marketingAssetUrl( kind: 'backdrop' | 'episode' | 'logo' | 'poster' | 'season', title: string, size: string ): string { return `${xtreamMockOrigin}/assets/marketing/${kind}/${slugifyForFile( title )}.svg?size=${encodeURIComponent(size)}`; } async function launchApp(dataDir: string): Promise { return electron.launch({ args: [electronMainPath], env: { ...process.env, ELECTRON_IS_DEV: '0', IPTVNATOR_E2E_DATA_DIR: dataDir, NODE_ENV: 'test', }, }); } async function findMainWindow(app: ElectronApplication): Promise { await new Promise((resolve) => setTimeout(resolve, 1500)); for (const candidate of app.windows()) { const title = await candidate.title(); if (!title.includes('DevTools')) { return candidate; } } return app.firstWindow(); } async function sizeElectronWindow(app: ElectronApplication): Promise { await app.evaluate(({ BrowserWindow }, size) => { const win = BrowserWindow.getAllWindows().find( (candidate) => !candidate.webContents.getTitle().includes('DevTools') ); win?.setSize(size.width, size.height); win?.center(); }, viewport); } async function waitForAppReady(page: Page): Promise { await page.waitForLoadState('domcontentloaded'); await page.waitForSelector('app-root', { timeout: 45_000 }); await page.waitForFunction( () => (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > 0, { timeout: 45_000 } ); } async function seedDemoData(page: Page, m3uPath: string): Promise { await addXtreamPortal(page); await addM3uPlaylist(page, m3uPath); await seedDashboardActivity(page); } async function addXtreamPortal(page: Page): Promise { await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container').last(); await clickDialogOption(dialog, /^xtream$/i); await dialog.locator('#title').fill('v0.20 Fictional Xtream'); await dialog.locator('#serverUrl').fill(xtreamMockOrigin); await dialog.locator('#username').fill('marketing'); await dialog.locator('#password').fill('marketing'); await dialog.getByRole('button', { name: /^(add|add playlist)$/i }).last().click(); await dialog.waitFor({ state: 'detached', timeout: 30_000 }); await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { timeout: 45_000, }); capturedXtreamPlaylistId = playlistIdFromUrl(page.url(), 'xtreams'); await page.locator('.category-content-layout, app-content-card').first().waitFor({ state: 'visible', timeout: 45_000, }); } async function addM3uPlaylist(page: Page, m3uPath: string): Promise { await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container').last(); await clickDialogOption(dialog, /^m3u$/i); await clickDialogOption(dialog, /add\s+via\s+file\s+upload/i); const fileInput = dialog.locator('input[type="file"][name="playlist"]'); await fileInput.evaluate((element, selectedFilePath) => { (element as HTMLInputElement).dataset.filePathOverride = selectedFilePath; }, m3uPath); await fileInput.setInputFiles(m3uPath); await dialog .getByRole('button', { name: /add playlist/i }) .last() .click({ timeout: 15_000 }); await dialog.waitFor({ state: 'detached', timeout: 30_000 }); await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { timeout: 45_000, }); capturedM3uPlaylistId = playlistIdFromUrl(page.url(), 'playlists'); try { await page.locator('[data-test-id="channel-item"]').first().waitFor({ state: 'visible', timeout: 60_000, }); } catch (error) { await writeDebugScreenshot(page, 'm3u-import'); const diagnostics = await page.evaluate(() => ({ bodyText: document.body.innerText.slice(0, 2000), title: document.title, url: location.href, })); console.error( `[capture] M3U import did not show channels: ${JSON.stringify( diagnostics, null, 2 )}` ); throw error; } } async function seedDashboardActivity(page: Page): Promise { const playlistId = requireCapturedPlaylistId('xtreams'); const items = [ { xtreamId: 62000, type: 'movie' as const, backdropUrl: marketingAssetUrl('backdrop', 'Crimson Skylark', '1280x720'), favorite: true, recent: true, }, { xtreamId: 62001, type: 'movie' as const, backdropUrl: marketingAssetUrl('backdrop', 'The Voltage Guard', '1280x720'), favorite: true, recent: true, }, { xtreamId: 62002, type: 'movie' as const, backdropUrl: marketingAssetUrl('backdrop', 'Midnight Mantle', '1280x720'), favorite: true, recent: false, }, { xtreamId: 72000, type: 'series' as const, backdropUrl: marketingAssetUrl('backdrop', 'Skyline Sentinels', '1280x720'), favorite: true, recent: true, }, { xtreamId: 72001, type: 'series' as const, backdropUrl: marketingAssetUrl('backdrop', 'The Aegis Club', '1280x720'), favorite: true, recent: false, }, ]; await page.evaluate( async ({ items, playlistId }) => { const electronApi = (window as typeof window & { electron?: { dbAddFavorite?: ( contentId: number, playlistId: string, backdropUrl?: string ) => Promise; dbAddRecentItem?: ( contentId: number, playlistId: string, backdropUrl?: string ) => Promise; dbGetContentByXtreamId?: ( xtreamId: number, playlistId: string, contentType?: 'live' | 'movie' | 'series' ) => Promise<{ id: number } | null>; }; }).electron; if (!electronApi?.dbGetContentByXtreamId) { throw new Error('Electron database bridge is unavailable.'); } for (const item of items) { const content = await electronApi.dbGetContentByXtreamId( item.xtreamId, playlistId, item.type ); if (!content?.id) { throw new Error( `Could not find imported Xtream content ${item.xtreamId}.` ); } if (item.favorite) { await electronApi.dbAddFavorite?.( content.id, playlistId, item.backdropUrl ); } if (item.recent) { await electronApi.dbAddRecentItem?.( content.id, playlistId, item.backdropUrl ); } } }, { items, playlistId } ); } async function openAddPlaylistDialog(page: Page): Promise { await page.getByRole('button', { name: /add playlist/i }).first().click(); await page.locator('mat-dialog-container').last().waitFor({ state: 'visible', timeout: 15_000, }); } async function clickDialogOption(dialog: ReturnType, label: RegExp): Promise { const tab = dialog.getByRole('tab', { name: label }).first(); if ((await tab.count()) > 0) { await tab.click(); return; } await dialog.getByRole('button', { name: label }).first().click(); } async function applyTheme(page: Page, theme: ThemeName): Promise { await openSettings(page); const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); await themeButton.scrollIntoViewIfNeeded(); await themeButton.click(); const saveButton = page.locator('[data-test-id="save-settings"]').first(); if (await saveButton.isEnabled()) { await saveButton.click(); await waitForTransientUiToSettle(page); } await page.waitForFunction( (expectedTheme) => document.body.classList.contains('dark-theme') === (expectedTheme === 'dark'), theme, { timeout: 10_000 } ); } async function openSettings(page: Page): Promise { await page.locator('a[href$="/workspace/settings"]').first().click(); await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); await page.locator('[data-test-id="settings-container"]').waitFor({ state: 'visible', timeout: 15_000, }); } async function captureThemeSet(page: Page, theme: ThemeName): Promise { await captureCurrentView(page, theme, 'settings'); await openDashboard(page); await captureCurrentView(page, theme, 'dashboard'); await openXtreamVodDetails(page); await captureCurrentView(page, theme, 'xtream-vod-details'); await openXtreamSeriesSeason(page); await captureCurrentView(page, theme, 'xtream-series-season-open'); await openM3uGroupsView(page); await captureCurrentView(page, theme, 'm3u-live-groups-two-column'); } async function openDashboard(page: Page): Promise { await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); await page.locator('[data-test-id="dashboard-page"]').waitFor({ state: 'visible', timeout: 20_000, }); await waitForTransientUiToSettle(page); await page.locator('[data-test-id="dashboard-hero"]').waitFor({ state: 'visible', timeout: 30_000, }); for (const testId of [ 'dashboard-recently-watched-rail', 'dashboard-global-favorites-rail', 'dashboard-recent-sources-rail', 'dashboard-xtream-recently-added-rail', ]) { await page.locator(`[data-test-id="${testId}"]`).waitFor({ state: 'visible', timeout: 30_000, }); } } async function waitForTransientUiToSettle(page: Page): Promise { await page .locator('.mat-mdc-snack-bar-container') .first() .waitFor({ state: 'detached', timeout: 10_000 }) .catch(() => undefined); await page.evaluate(() => { document .querySelectorAll('.mat-mdc-snack-bar-container, simple-snack-bar') .forEach((element) => { const overlayPane = element.closest('.cdk-overlay-pane'); (overlayPane ?? element).remove(); }); }); await page.waitForTimeout(250); } async function openXtreamVodDetails(page: Page): Promise { const xtreamId = requireCapturedPlaylistId('xtreams'); await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/vod`); await clickCategoryByName(page, 'Hero Premieres'); await clickFirstGridCard(page); await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, { timeout: 30_000, }); await page.locator('app-content-hero').waitFor({ state: 'visible', timeout: 30_000, }); await page.waitForTimeout(700); } async function openXtreamSeriesSeason(page: Page): Promise { const xtreamId = requireCapturedPlaylistId('xtreams'); await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/series`); await clickCategoryByName(page, 'Hero Teams'); await clickFirstGridCard(page); await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, { timeout: 30_000, }); await page.locator('app-season-container').waitFor({ state: 'visible', timeout: 30_000, }); await page.locator('.season-card').first().click(); await page.locator('.episode-card, .episode-list-item').first().waitFor({ state: 'visible', timeout: 20_000, }); await page.waitForTimeout(700); } async function openM3uGroupsView(page: Page): Promise { const playlistId = requireCapturedPlaylistId('playlists'); await switchPlaylistFromHeader(page, 'v020-demo'); await page.waitForURL(new RegExp(`/workspace/playlists/${playlistId}/`), { timeout: 20_000, }); await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`); await page.waitForURL(/\/workspace\/playlists\/[^/]+\/groups/, { timeout: 20_000, }); await page.locator('.group-nav-item').first().waitFor({ state: 'visible', timeout: 20_000, }); await page.locator('.group-nav-item').first().click(); await page.locator('[data-test-id="channel-item"]').first().click(); await page.waitForTimeout(1_000); } async function switchPlaylistFromHeader( page: Page, title: string ): Promise { await page.locator('app-playlist-switcher .playlist-switcher-trigger').click(); const item = page .locator('.cdk-overlay-pane .playlist-item') .filter({ hasText: title }) .first(); await item.waitFor({ state: 'visible', timeout: 20_000 }); await item.click(); await page.locator('.cdk-overlay-pane .playlist-item').first().waitFor({ state: 'detached', timeout: 10_000, }); } async function clickCategoryByName( page: Page, categoryName: string ): Promise { const category = page .locator('app-workspace-context-panel .category-item') .filter({ hasText: categoryName }) .first(); try { await category.waitFor({ state: 'visible', timeout: 30_000 }); } catch (error) { await writeDebugScreenshot(page, `category-${slugifyForFile(categoryName)}`); const diagnostics = await page.evaluate(() => ({ bodyText: document.body.innerText.slice(0, 2500), categories: Array.from( document.querySelectorAll( 'app-workspace-context-panel .category-item' ) ).map((element) => ({ id: element.getAttribute('data-category-id'), text: (element.textContent ?? '').trim(), })), title: document.title, url: location.href, })); console.error( `[capture] Category ${categoryName} was not visible: ${JSON.stringify( diagnostics, null, 2 )}` ); throw error; } await category.click(); await page.waitForTimeout(600); } async function clickFirstGridCard(page: Page): Promise { const card = page.locator('.category-content-layout mat-card').first(); await card.waitFor({ state: 'visible', timeout: 30_000 }); await card.click(); } function requireCapturedPlaylistId( provider: 'playlists' | 'xtreams' ): string { const id = provider === 'playlists' ? capturedM3uPlaylistId : capturedXtreamPlaylistId; if (!id) { throw new Error(`Could not resolve captured ${provider} playlist id.`); } return id; } function playlistIdFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { const match = url.match(new RegExp(`/workspace/${provider}/([^/]+)`)); if (!match?.[1]) { throw new Error(`Could not resolve ${provider} playlist id from URL ${url}`); } return match[1]; } async function clickHrefSuffix(page: Page, suffix: string): Promise { const link = page.locator(`a[href$="${suffix}"]`).first(); await link.waitFor({ state: 'attached', timeout: 20_000 }); await link.click({ force: true }); await page.waitForTimeout(500); } async function captureCurrentView( page: Page, theme: ThemeName, slug: ScreenshotSlug ): Promise { const originalPath = screenshotPath('original', theme, slug); mkdirSync(path.dirname(originalPath), { recursive: true }); await prepareForScreenshot(page); await page.screenshot({ path: originalPath, type: 'png', fullPage: slug === 'dashboard', scale: 'css', }); await createDesignedCopy( page, originalPath, screenshotPath('designed', theme, slug), { ...captureTargets.find((target) => target.slug === slug)!, theme, } ); } async function prepareForScreenshot(page: Page): Promise { await page.mouse.move(viewport.width - 32, viewport.height - 32); await page.keyboard.press('Escape').catch(() => undefined); await waitForTransientUiToSettle(page); await page .locator('.mat-mdc-tooltip, .mdc-tooltip, .mat-tooltip-panel') .first() .waitFor({ state: 'detached', timeout: 1_500 }) .catch(() => undefined); await page.waitForTimeout(200); } async function writeDebugScreenshot(page: Page, name: string): Promise { const debugPath = path.join(outputRoot, 'debug', `${name}.png`); mkdirSync(path.dirname(debugPath), { recursive: true }); await page.screenshot({ path: debugPath, fullPage: true, type: 'png' }); } function screenshotPath( variant: 'designed' | 'original', theme: ThemeName, slug: ScreenshotSlug ): string { return path.join(outputRoot, variant, theme, `${slug}.png`); } async function createDesignedCopy( page: Page, inputPath: string, outputPath: string, options: CaptureTarget & { theme: ThemeName } ): Promise { mkdirSync(path.dirname(outputPath), { recursive: true }); const canvasWidth = 1920; const canvasHeight = 1080; const screenshotWidth = 1460; const screenshotHeight = 822; const left = 360; const top = 170; const palette = options.theme === 'dark' ? { bg1: '#090b12', bg2: '#172235', accent: '#49c6e5', text: '#f8fafc', muted: '#a7b4c8', } : { bg1: '#eef2f7', bg2: '#dce7ef', accent: '#2563eb', text: '#111827', muted: '#475569', }; const dataUrl = await page.evaluate( async ({ inputDataUrl, options, palette, canvasWidth, canvasHeight, screenshotWidth, screenshotHeight, left, top, }) => { const image = await loadCanvasImage(inputDataUrl); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { throw new Error('Canvas 2D context is unavailable.'); } canvas.width = canvasWidth; canvas.height = canvasHeight; const background = context.createLinearGradient( 0, 0, canvasWidth, canvasHeight ); background.addColorStop(0, palette.bg1); background.addColorStop(1, palette.bg2); context.fillStyle = background; context.fillRect(0, 0, canvasWidth, canvasHeight); context.globalAlpha = 0.16; context.fillStyle = palette.accent; context.beginPath(); context.arc(1710, 140, 260, 0, Math.PI * 2); context.fill(); context.globalAlpha = 0.12; context.beginPath(); context.arc(160, 930, 340, 0, Math.PI * 2); context.fill(); context.globalAlpha = 0.28; context.strokeStyle = palette.accent; context.lineWidth = 4; context.beginPath(); context.moveTo(0, 810); context.bezierCurveTo(420, 650, 720, 1040, 1220, 840); context.bezierCurveTo(1460, 744, 1720, 610, 1920, 720); context.stroke(); context.globalAlpha = 1; context.fillStyle = palette.accent; context.font = '800 28px Arial, Helvetica, sans-serif'; context.fillText(options.kicker.toUpperCase(), 110, 150); context.fillStyle = palette.text; context.font = '900 72px Arial, Helvetica, sans-serif'; context.fillText(options.title, 110, 220); context.fillStyle = palette.muted; context.font = '600 28px Arial, Helvetica, sans-serif'; context.fillText( 'IPTVnator v0.20 release preview using fictional demo content', 110, 278 ); context.save(); context.shadowColor = options.theme === 'dark' ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)'; context.shadowBlur = 76; context.shadowOffsetY = 34; context.globalAlpha = options.theme === 'dark' ? 0.78 : 0.9; context.fillStyle = options.theme === 'dark' ? '#020617' : '#ffffff'; drawRoundRect( context, left - 18, top - 18, screenshotWidth + 36, screenshotHeight + 36, 34 ); context.fill(); context.restore(); context.save(); drawRoundRect(context, left, top, screenshotWidth, screenshotHeight, 18); context.clip(); context.drawImage( image, 0, 0, image.naturalWidth, image.naturalHeight, left, top, screenshotWidth, screenshotHeight ); context.restore(); context.strokeStyle = hexToRgba(palette.accent, 0.35); context.lineWidth = 2; drawRoundRect( context, left - 18, top - 18, screenshotWidth + 36, screenshotHeight + 36, 34 ); context.stroke(); return canvas.toDataURL('image/png'); function loadCanvasImage(src: string): Promise { return new Promise((resolve, reject) => { const candidate = new Image(); candidate.onload = () => resolve(candidate); candidate.onerror = () => reject(new Error('Could not load screenshot image.')); candidate.src = src; }); } function drawRoundRect( context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number ): void { context.beginPath(); context.moveTo(x + radius, y); context.lineTo(x + width - radius, y); context.quadraticCurveTo(x + width, y, x + width, y + radius); context.lineTo(x + width, y + height - radius); context.quadraticCurveTo( x + width, y + height, x + width - radius, y + height ); context.lineTo(x + radius, y + height); context.quadraticCurveTo(x, y + height, x, y + height - radius); context.lineTo(x, y + radius); context.quadraticCurveTo(x, y, x + radius, y); context.closePath(); } function hexToRgba(hex: string, alpha: number): string { const normalized = hex.replace('#', ''); const red = Number.parseInt(normalized.slice(0, 2), 16); const green = Number.parseInt(normalized.slice(2, 4), 16); const blue = Number.parseInt(normalized.slice(4, 6), 16); return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } }, { inputDataUrl: fileToDataUrl(inputPath), options, palette, canvasWidth, canvasHeight, screenshotWidth, screenshotHeight, left, top, } ); writeDataUrlPng(outputPath, dataUrl); } async function createHeroImage(page: Page): Promise { const dashboard = screenshotPath('original', 'dark', 'dashboard'); const vod = screenshotPath('original', 'dark', 'xtream-vod-details'); const series = screenshotPath('original', 'light', 'xtream-series-season-open'); mkdirSync(path.dirname(heroOutputPath), { recursive: true }); const dataUrl = await page.evaluate( async ({ dashboard, vod, series }) => { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { throw new Error('Canvas 2D context is unavailable.'); } canvas.width = 1600; canvas.height = 900; const background = context.createLinearGradient(0, 0, 1600, 900); background.addColorStop(0, '#06111f'); background.addColorStop(0.52, '#111827'); background.addColorStop(1, '#233b54'); context.fillStyle = background; context.fillRect(0, 0, 1600, 900); context.globalAlpha = 0.18; context.fillStyle = '#49c6e5'; context.beginPath(); context.arc(1380, 120, 240, 0, Math.PI * 2); context.fill(); context.globalAlpha = 0.15; context.fillStyle = '#f2a65a'; context.beginPath(); context.arc(170, 790, 290, 0, Math.PI * 2); context.fill(); context.globalAlpha = 1; context.fillStyle = '#49c6e5'; context.font = '800 30px Arial, Helvetica, sans-serif'; context.fillText('IPTVNATOR V0.20', 86, 138); context.fillStyle = '#f8fafc'; context.font = '900 78px Arial, Helvetica, sans-serif'; context.fillText('Unified UI.', 86, 224); context.fillText('Content-first.', 86, 306); context.fillStyle = '#b9c4d6'; context.font = '600 28px Arial, Helvetica, sans-serif'; context.fillText( 'Dashboard, search, favorites, history, and settings reworked across M3U, Xtream, and Stalker.', 88, 366 ); await drawFramedImage(context, dashboard, 650, 130, 780, 438, 26, '#020617'); await drawFramedImage(context, vod, 500, 505, 520, 292, 22, '#020617'); await drawFramedImage(context, series, 990, 565, 430, 242, 20, '#f8fafc'); return canvas.toDataURL('image/png'); async function drawFramedImage( context: CanvasRenderingContext2D, src: string, x: number, y: number, width: number, height: number, radius: number, frameColor: string ): Promise { const image = await loadCanvasImage(src); context.save(); context.shadowColor = 'rgba(0, 0, 0, 0.45)'; context.shadowBlur = 60; context.shadowOffsetY = 28; context.globalAlpha = frameColor === '#f8fafc' ? 0.94 : 0.82; context.fillStyle = frameColor; drawRoundRect(context, x, y, width, height, radius); context.fill(); context.restore(); context.save(); drawRoundRect(context, x, y, width, height, radius); context.clip(); context.drawImage( image, 0, 0, image.naturalWidth, image.naturalHeight, x, y, width, height ); context.restore(); } function loadCanvasImage(src: string): Promise { return new Promise((resolve, reject) => { const candidate = new Image(); candidate.onload = () => resolve(candidate); candidate.onerror = () => reject(new Error('Could not load screenshot image.')); candidate.src = src; }); } function drawRoundRect( context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number ): void { context.beginPath(); context.moveTo(x + radius, y); context.lineTo(x + width - radius, y); context.quadraticCurveTo(x + width, y, x + width, y + radius); context.lineTo(x + width, y + height - radius); context.quadraticCurveTo( x + width, y + height, x + width - radius, y + height ); context.lineTo(x + radius, y + height); context.quadraticCurveTo(x, y + height, x, y + height - radius); context.lineTo(x, y + radius); context.quadraticCurveTo(x, y, x + radius, y); context.closePath(); } }, { dashboard: fileToDataUrl(dashboard), vod: fileToDataUrl(vod), series: fileToDataUrl(series), } ); writeDataUrlPng(heroOutputPath, dataUrl); } async function verifyExpectedAssets(): Promise { const expected = [ heroOutputPath, ...(['dark', 'light'] as ThemeName[]).flatMap((theme) => captureTargets.flatMap((target) => [ screenshotPath('original', theme, target.slug), screenshotPath('designed', theme, target.slug), ]) ), ]; for (const filePath of expected) { if (!fileExists(filePath)) { throw new Error(`Expected screenshot asset was not created: ${filePath}`); } const dimensions = readPngDimensions(filePath); if (!dimensions.width || !dimensions.height) { throw new Error(`Screenshot asset has invalid dimensions: ${filePath}`); } } } async function isHealthy(url: string): Promise { try { const response = await fetch(url); return response.ok; } catch { return false; } } async function waitForHealthy(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isHealthy(url)) { return; } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error(`Timed out waiting for ${url}`); } function fileExists(filePath: string): boolean { try { accessSync(filePath); return true; } catch { return false; } } function fileToDataUrl(filePath: string): string { return `data:image/png;base64,${readFileSync(filePath).toString('base64')}`; } function writeDataUrlPng(filePath: string, dataUrl: string): void { const marker = 'data:image/png;base64,'; if (!dataUrl.startsWith(marker)) { throw new Error(`Expected PNG data URL for ${filePath}`); } writeFileSync(filePath, Buffer.from(dataUrl.slice(marker.length), 'base64')); } function readPngDimensions(filePath: string): { height: number; width: number } { const buffer = readFileSync(filePath); const pngSignature = '89504e470d0a1a0a'; if (buffer.subarray(0, 8).toString('hex') !== pngSignature) { throw new Error(`Expected PNG signature in ${filePath}`); } return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), }; } function slugifyForFile(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } main().catch((error) => { console.error(error); process.exit(1); });