diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index a41f89880..304ae7cb9 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -284,8 +284,9 @@ This scenario is reserved for release screenshots and marketing materials: including documentary, noir, animated family, retro rescue drama, cyberpunk, anime-inspired space school, and workplace dramedy styles - screenshot capture remains deterministic and offline because - `tools/release/capture-v020-screenshots.ts` consumes the local mock server - assets and never calls OpenAI + `tools/release/capture-release-screenshots.ts` consumes the local mock server + assets and never calls OpenAI; its guards additionally verify that the + service answering on the mock port really is this fixture server - the SVG renderer in `marketing.generator.ts` remains the fallback for missing assets, live logos, season covers, and episode thumbnails diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 6ef2f9985..73258275c 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -130,6 +130,5 @@ export const maxLinesBaseline = [ 'libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts', 'libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts', 'libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts', - 'tools/release/capture-v020-screenshots.ts', 'tools/release/generate-marketing-artwork.ts', ]; diff --git a/tools/release/capture-v020-screenshots.ts b/tools/release/capture-v020-screenshots.ts deleted file mode 100644 index eb09fce16..000000000 --- a/tools/release/capture-v020-screenshots.ts +++ /dev/null @@ -1,1165 +0,0 @@ -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); -});