Files
KiraTV---App-Linux/tools/release/capture-v020-screenshots.ts
2026-05-03 23:43:09 +02:00

1166 lines
38 KiB
TypeScript

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<void> {
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<ChildProcess | undefined> {
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<ElectronApplication> {
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<Page> {
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<void> {
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<void> {
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<void> {
await addXtreamPortal(page);
await addM3uPlaylist(page, m3uPath);
await seedDashboardActivity(page);
}
async function addXtreamPortal(page: Page): Promise<void> {
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<void> {
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<void> {
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<unknown>;
dbAddRecentItem?: (
contentId: number,
playlistId: string,
backdropUrl?: string
) => Promise<unknown>;
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<void> {
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<Page['locator']>, label: RegExp): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<HTMLImageElement> {
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<void> {
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<void> {
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<HTMLImageElement> {
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<void> {
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<boolean> {
try {
const response = await fetch(url);
return response.ok;
} catch {
return false;
}
}
async function waitForHealthy(url: string, timeoutMs: number): Promise<void> {
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);
});