Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Spanish copy for the Xtream / workspace shell UI (replaces merged English in es.json).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const esPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'apps/web/src/assets/i18n/es.json'
|
||||
);
|
||||
|
||||
const j = JSON.parse(fs.readFileSync(esPath, 'utf8'));
|
||||
|
||||
const shell = {
|
||||
GO_TO_DASHBOARD: 'Volver al panel',
|
||||
GO_TO_SOURCES: 'Ir a fuentes',
|
||||
DROP_OVERLAY_TITLE: 'Suelta para añadir lista',
|
||||
DROP_OVERLAY_BODY:
|
||||
'Suelta un archivo .m3u o .m3u8 en cualquier parte del espacio de trabajo para importarlo.',
|
||||
DROP_OVERLAY_REJECTED_BODY:
|
||||
'Suelta un archivo .m3u o .m3u8. Pulsa Esc para cerrar.',
|
||||
DROP_OVERLAY_REJECTED_UNSUPPORTED: 'Tipo de archivo no admitido',
|
||||
DROP_OVERLAY_REJECTED_EMPTY: 'Ese archivo está vacío',
|
||||
DROP_OVERLAY_REJECTED_READ_ERROR: 'No se pudo leer el archivo',
|
||||
DROP_IMPORT_SUCCESS: 'Importando «{{title}}»…',
|
||||
EXTERNAL_PLAYBACK_CLOSE: 'Cerrar reproductor',
|
||||
BRAND_ALT: 'IPTVnator',
|
||||
RAIL_DASHBOARD: 'Panel',
|
||||
OPEN_DASHBOARD: 'Abrir panel',
|
||||
RAIL_SOURCES: 'Fuentes',
|
||||
OPEN_SOURCES: 'Abrir fuentes',
|
||||
RAIL_GLOBAL_FAVORITES: 'Favoritos globales',
|
||||
RAIL_GLOBAL_RECENT: 'Visto recientemente',
|
||||
RAIL_SETTINGS: 'Ajustes',
|
||||
OPEN_SETTINGS: 'Abrir ajustes',
|
||||
RAIL_LIBRARY: 'Biblioteca',
|
||||
RAIL_MOVIES: 'Películas',
|
||||
RAIL_LIVE: 'TV en directo',
|
||||
RAIL_SERIES: 'Series',
|
||||
RAIL_RECENTLY_ADDED: 'Añadidos recientemente',
|
||||
RAIL_SEARCH: 'Búsqueda avanzada',
|
||||
RAIL_RECENT: 'Visto recientemente',
|
||||
RAIL_FAVORITES: 'Favoritos',
|
||||
RAIL_DOWNLOADS: 'Descargas',
|
||||
RAIL_ALL_CHANNELS: 'Todos los canales',
|
||||
RAIL_GROUPS: 'Grupos',
|
||||
RAIL_CONTEXT_ACTIONS: 'Acciones de la sección',
|
||||
SEARCH_LABEL: 'Buscar',
|
||||
SEARCH_SOURCES_PLACEHOLDER: 'Buscar fuentes (todas las listas)…',
|
||||
SEARCH_PLAYLIST_PLACEHOLDER: 'Buscar en esta lista…',
|
||||
SEARCH_SECTION_PLACEHOLDER: 'Buscar en esta sección…',
|
||||
FILTER_SECTION_PLACEHOLDER: 'Filtrar esta sección…',
|
||||
SEARCH_STATUS_LOADED_ONLY: 'Solo canales cargados',
|
||||
COMMAND_PALETTE_SHORTCUT: 'Paleta de comandos (Ctrl/Cmd+K)',
|
||||
OPEN_COMMAND_PALETTE: 'Abrir paleta de comandos',
|
||||
ADD_PLAYLIST_ALL: 'Añadir lista',
|
||||
ADD_PLAYLIST: 'Añadir lista',
|
||||
OPEN_DOWNLOADS: 'Abrir descargas',
|
||||
OPEN_CONTEXT_ACTIONS: 'Abrir acciones de la sección',
|
||||
NO_SECTION_ACTIONS: 'No hay acciones para esta sección',
|
||||
PLAYLIST_INFO: 'Info de la lista',
|
||||
ACCOUNT_INFO: 'Info de la cuenta',
|
||||
REFRESH_PLAYLIST: 'Actualizar lista',
|
||||
CLEAR_COMPLETED_DOWNLOADS_ALL: 'Borrar descargas completadas (todas las listas)',
|
||||
CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA:
|
||||
'Borrar descargas completadas de todas las listas',
|
||||
CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST:
|
||||
'Borrar descargas completadas (esta lista)',
|
||||
CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA:
|
||||
'Borrar descargas completadas de esta lista',
|
||||
CLEAR_RECENTLY_VIEWED_SECTION: 'Borrar visto recientemente',
|
||||
CLEAR_RECENTLY_VIEWED_SECTION_ARIA:
|
||||
'Borrar visto recientemente de esta sección',
|
||||
CLEAR_RECENTLY_VIEWED_TYPE: 'Borrar visto recientemente ({{type}})',
|
||||
CLEAR_RECENTLY_VIEWED_DIALOG_TITLE:
|
||||
'¿Borrar visto recientemente ({{type}})?',
|
||||
CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST:
|
||||
'Se quitarán del historial todos los elementos de {{type}} de esta lista. No se puede deshacer.',
|
||||
CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL:
|
||||
'Se quitarán del historial todos los elementos de {{type}} de todas las listas. No se puede deshacer.',
|
||||
CLEAR_FAVORITES_TYPE: 'Borrar favoritos de {{type}}',
|
||||
CLEAR_FAVORITES_DIALOG_TITLE: '¿Quitar todos los favoritos de {{type}}?',
|
||||
CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST:
|
||||
'Se quitarán todos los favoritos de {{type}} de esta lista. No se puede deshacer.',
|
||||
CLEAR_FAVORITES_DIALOG_MESSAGE_ALL:
|
||||
'Se quitarán todos los favoritos de {{type}} de todas las listas. No se puede deshacer.',
|
||||
XTREAM_CODE: 'Código Xtream',
|
||||
STALKER_PORTAL: 'Portal Stalker',
|
||||
CHANNELS_COUNT: '{{count}} canales',
|
||||
NO_SOURCES_AVAILABLE: 'No hay fuentes disponibles',
|
||||
ONE_SOURCE_AVAILABLE: '1 fuente disponible',
|
||||
SOURCES_AVAILABLE: '{{count}} fuentes disponibles',
|
||||
XTREAM_IMPORT_TITLE: 'Sincronizando lista',
|
||||
XTREAM_IMPORT_REMOTE_BADGE: 'Fuente remota',
|
||||
XTREAM_IMPORT_LOCAL_BADGE: 'Biblioteca local',
|
||||
XTREAM_IMPORT_DETAIL_REMOTE: 'Esperando respuesta del proveedor.',
|
||||
XTREAM_IMPORT_DETAIL_LOCAL:
|
||||
'Preparando un cambio de lista más rápido para la próxima visita.',
|
||||
XTREAM_IMPORT_PREPARING: 'Preparando biblioteca local…',
|
||||
XTREAM_IMPORT_LOADING: 'Obteniendo datos de la lista…',
|
||||
XTREAM_IMPORT_SAVING: 'Guardando datos de la lista en local…',
|
||||
XTREAM_IMPORT_RESTORING_FAVORITES: 'Restaurando favoritos desde local…',
|
||||
XTREAM_IMPORT_RESTORING_RECENT: 'Restaurando visto recientemente desde local…',
|
||||
XTREAM_IMPORT_PROGRESS: '{{type}} importados: {{current}} / {{total}}',
|
||||
XTREAM_IMPORT_CANCEL: 'Detener sincronización',
|
||||
XTREAM_IMPORT_CANCELLING: 'Deteniendo sincronización…',
|
||||
};
|
||||
|
||||
const shellCommands = {
|
||||
ADD_PLAYLIST_M3U_LABEL: 'Añadir lista M3U',
|
||||
ADD_PLAYLIST_M3U_DESCRIPTION:
|
||||
'Abrir el diálogo en la pestaña de archivo o URL M3U',
|
||||
ADD_PLAYLIST_XTREAM_LABEL: 'Añadir lista Xtream Codes',
|
||||
ADD_PLAYLIST_XTREAM_DESCRIPTION:
|
||||
'Abrir el diálogo en la pestaña Xtream Codes',
|
||||
ADD_PLAYLIST_STALKER_LABEL: 'Añadir portal Stalker',
|
||||
ADD_PLAYLIST_STALKER_DESCRIPTION:
|
||||
'Abrir el diálogo en la pestaña Portal Stalker',
|
||||
GLOBAL_SEARCH_LABEL: 'Buscar en todas las listas Xtream',
|
||||
GLOBAL_SEARCH_DESCRIPTION: 'Abrir búsqueda global',
|
||||
PLAYLIST_SEARCH_LABEL: 'Búsqueda avanzada en esta lista',
|
||||
PLAYLIST_SEARCH_DESCRIPTION: 'Abrir búsqueda avanzada de la lista',
|
||||
OPEN_GLOBAL_FAVORITES_LABEL: 'Abrir favoritos globales',
|
||||
OPEN_GLOBAL_FAVORITES_DESCRIPTION: 'Ir a favoritos agregados',
|
||||
OPEN_DOWNLOADS_LABEL: 'Abrir descargas',
|
||||
OPEN_DOWNLOADS_DESCRIPTION: 'Ir a la vista de descargas',
|
||||
OPEN_GLOBAL_RECENT_LABEL: 'Abrir visto recientemente',
|
||||
OPEN_GLOBAL_RECENT_DESCRIPTION: 'Abrir página global de recientes',
|
||||
OPEN_SETTINGS_LABEL: 'Abrir ajustes',
|
||||
OPEN_SETTINGS_DESCRIPTION: 'Ir a los ajustes de la aplicación',
|
||||
OPEN_SOURCES_LABEL: 'Gestionar fuentes',
|
||||
OPEN_SOURCES_DESCRIPTION: 'Ir a la gestión de fuentes',
|
||||
OPEN_DASHBOARD_LABEL: 'Abrir panel',
|
||||
OPEN_DASHBOARD_DESCRIPTION: 'Ir al panel',
|
||||
ADD_PLAYLIST_LABEL: 'Añadir fuente',
|
||||
ADD_PLAYLIST_DESCRIPTION: 'Abrir diálogo para añadir lista',
|
||||
REFRESH_PLAYLIST_LABEL: 'Actualizar lista',
|
||||
REFRESH_PLAYLIST_DESCRIPTION: 'Volver a sincronizar con el origen',
|
||||
PLAYLIST_INFO_LABEL: 'Detalles de la lista',
|
||||
PLAYLIST_INFO_DESCRIPTION: 'Mostrar información de la lista actual',
|
||||
ACCOUNT_INFO_LABEL: 'Info de la cuenta',
|
||||
ACCOUNT_INFO_DESCRIPTION: 'Mostrar datos de la cuenta Xtream',
|
||||
OPEN_VIEW_DESCRIPTION: 'Ir a {{view}}',
|
||||
CLEAR_CURRENT_VIEW_DESCRIPTION:
|
||||
'Borrar todos los elementos visibles de {{type}} en esta vista',
|
||||
OPEN_MULTI_EPG_DESCRIPTION: 'Abrir la guía multi-canal',
|
||||
GO_TO_VOD_LABEL: 'Películas',
|
||||
GO_TO_VOD_DESCRIPTION: 'Ir a películas',
|
||||
GO_TO_LIVE_LABEL: 'TV en directo',
|
||||
GO_TO_LIVE_DESCRIPTION: 'Ir a TV en directo',
|
||||
GO_TO_SERIES_LABEL: 'Series',
|
||||
GO_TO_SERIES_DESCRIPTION: 'Ir a series',
|
||||
};
|
||||
|
||||
const context = {
|
||||
CATEGORIES: 'Categorías',
|
||||
MOVIE_CATEGORIES: 'Categorías de películas',
|
||||
SERIES_CATEGORIES: 'Categorías de series',
|
||||
LIVE_CATEGORIES: 'Categorías en directo',
|
||||
XTREAM_SYNCING_MOVIES: 'Sincronizando categorías de películas…',
|
||||
XTREAM_SYNCING_SERIES: 'Sincronizando categorías de series…',
|
||||
XTREAM_SYNCING_LIVE: 'Sincronizando categorías en directo…',
|
||||
XTREAM_SOURCE: 'Fuente Xtream',
|
||||
STALKER_PORTAL: 'Portal Stalker',
|
||||
MANAGE_CATEGORIES: 'Gestionar categorías',
|
||||
LOAD_CATEGORIES_ERROR: 'No se pudieron cargar las categorías',
|
||||
LOAD_CATEGORIES_ERROR_HINT:
|
||||
'Reintenta o comprueba la configuración del portal.',
|
||||
FILTER_BY_TYPE: 'Filtrar por tipo',
|
||||
ITEM_COUNT_ONE: '1 elemento',
|
||||
ITEM_COUNT_OTHER: '{{count}} elementos',
|
||||
SEARCH: 'Buscar',
|
||||
SEARCH_CATEGORIES: 'Buscar categorías',
|
||||
};
|
||||
|
||||
const commandPalette = {
|
||||
TITLE: 'Paleta de comandos',
|
||||
SUBTITLE: 'Acciones globales y del contexto',
|
||||
SEARCH_PLACEHOLDER: 'Escribe para filtrar comandos',
|
||||
SEARCH_LABEL: 'Filtrar comandos',
|
||||
EMPTY: 'No se encontraron comandos',
|
||||
GROUP_GLOBAL: 'Acciones globales',
|
||||
GROUP_PLAYLIST: 'Esta lista',
|
||||
GROUP_VIEW: 'Vista actual',
|
||||
GROUP_SECTION: 'Esta sección',
|
||||
};
|
||||
|
||||
j.WORKSPACE = j.WORKSPACE || {};
|
||||
j.WORKSPACE.SHELL = { ...j.WORKSPACE.SHELL, ...shell };
|
||||
j.WORKSPACE.SHELL.COMMANDS = {
|
||||
...j.WORKSPACE.SHELL.COMMANDS,
|
||||
...shellCommands,
|
||||
};
|
||||
j.WORKSPACE.CONTEXT = { ...j.WORKSPACE.CONTEXT, ...context };
|
||||
j.WORKSPACE.COMMAND_PALETTE = {
|
||||
...j.WORKSPACE.COMMAND_PALETTE,
|
||||
...commandPalette,
|
||||
};
|
||||
|
||||
if (j.PORTALS) {
|
||||
j.PORTALS.SELECT_CATEGORY = 'Selecciona una categoría';
|
||||
}
|
||||
|
||||
if (j.CANCEL === 'Cancel') j.CANCEL = 'Cancelar';
|
||||
if (j.CLEAR === 'Clear') j.CLEAR = 'Limpiar';
|
||||
if (j.SHOW_MORE === 'More') j.SHOW_MORE = 'Más';
|
||||
if (j.SHOW_LESS === 'Less') j.SHOW_LESS = 'Menos';
|
||||
if (j.PLAYLISTS === 'Playlists') j.PLAYLISTS = 'Listas de reproducción';
|
||||
|
||||
if (j.REMOTE_CONTROL?.TITLE === 'Remote control') {
|
||||
j.REMOTE_CONTROL = {
|
||||
...j.REMOTE_CONTROL,
|
||||
TITLE: 'Control remoto',
|
||||
SUBTITLE: 'Controla el reproductor IPTV a distancia',
|
||||
UP_CHANNEL: 'Canal siguiente',
|
||||
DOWN_CHANNEL: 'Canal anterior',
|
||||
HEADER: 'Control remoto IPTVnator',
|
||||
FOOTER: 'Control remoto IPTVnator',
|
||||
};
|
||||
}
|
||||
|
||||
fs.writeFileSync(esPath, JSON.stringify(j, null, 4) + '\n');
|
||||
console.log('Applied Spanish UI strings for WORKSPACE shell / PORTALS / root keys.');
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Recursively copies missing keys from en.json into es.json (does not overwrite
|
||||
* existing Spanish strings). Fixes ngx-translate showing raw keys like WORKSPACE.SHELL.*.
|
||||
*
|
||||
* After running this, optionally run `node tools/i18n/apply-shell-es-ui.mjs` to
|
||||
* replace merged English workspace shell strings with Spanish for KiraTV / es UI.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const enPath = path.join(root, 'apps/web/src/assets/i18n/en.json');
|
||||
const esPath = path.join(root, 'apps/web/src/assets/i18n/es.json');
|
||||
|
||||
function fillMissing(enVal, esVal) {
|
||||
if (enVal === null || typeof enVal !== 'object' || Array.isArray(enVal)) {
|
||||
return esVal !== undefined ? esVal : enVal;
|
||||
}
|
||||
const out =
|
||||
esVal !== null && typeof esVal === 'object' && !Array.isArray(esVal)
|
||||
? { ...esVal }
|
||||
: {};
|
||||
for (const [k, v] of Object.entries(enVal)) {
|
||||
if (out[k] === undefined) {
|
||||
out[k] = Array.isArray(v) ? [...v] : v;
|
||||
} else if (
|
||||
v !== null &&
|
||||
typeof v === 'object' &&
|
||||
!Array.isArray(v) &&
|
||||
typeof out[k] === 'object' &&
|
||||
!Array.isArray(out[k])
|
||||
) {
|
||||
out[k] = fillMissing(v, out[k]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const en = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const es = JSON.parse(fs.readFileSync(esPath, 'utf8'));
|
||||
const merged = fillMissing(en, es);
|
||||
fs.writeFileSync(esPath, JSON.stringify(merged, null, 4) + '\n');
|
||||
console.log('Merged missing i18n keys from en.json into es.json');
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Downloads the official VLC for Windows x64 zip and unpacks it into
|
||||
* tools/packaging/bundled-vlc/win64/ (vlc.exe at the root of that folder).
|
||||
* Used by make-kiratv-windows-nsis.mjs so the NSIS installer ships VLC in resources/vlc.
|
||||
*
|
||||
* GPL-2.0+ applies to VLC; see https://www.videolan.org/legal.html
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const destDir = path.join(root, 'tools', 'packaging', 'bundled-vlc', 'win64');
|
||||
const VLC_VERSION = '3.0.21';
|
||||
const zipName = `vlc-${VLC_VERSION}-win64.zip`;
|
||||
const zipUrl = `https://get.videolan.org/vlc/${VLC_VERSION}/win64/${zipName}`;
|
||||
|
||||
const force = process.argv.includes('--force');
|
||||
const vlcExe = path.join(destDir, 'vlc.exe');
|
||||
|
||||
if (!force && fs.existsSync(vlcExe)) {
|
||||
console.log('VLC bundle already present:', vlcExe);
|
||||
console.log('Use --force to re-download.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tmpParent = path.join(root, 'tmp');
|
||||
fs.mkdirSync(tmpParent, { recursive: true });
|
||||
const tmpRoot = fs.mkdtempSync(path.join(tmpParent, 'vlc-dl-'));
|
||||
const zipPath = path.join(tmpRoot, zipName);
|
||||
const extractDir = path.join(tmpRoot, 'extracted');
|
||||
|
||||
try {
|
||||
console.log('Downloading', zipUrl);
|
||||
const res = await fetch(zipUrl, { redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
if (process.platform === 'win32') {
|
||||
execSync(`tar -xf "${zipPath}" -C "${extractDir}"`, {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
} else {
|
||||
execSync(`unzip -o -q "${zipPath}" -d "${extractDir}"`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
const top = fs.readdirSync(extractDir, { withFileTypes: true });
|
||||
const innerDir = top.find((e) => e.isDirectory());
|
||||
if (!innerDir) {
|
||||
throw new Error('Unexpected zip layout: no top-level directory');
|
||||
}
|
||||
const innerPath = path.join(extractDir, innerDir.name);
|
||||
const innerVlc = path.join(innerPath, 'vlc.exe');
|
||||
if (!fs.existsSync(innerVlc)) {
|
||||
throw new Error('Unexpected zip layout: vlc.exe not found under ' + innerPath);
|
||||
}
|
||||
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
fs.cpSync(innerPath, destDir, { recursive: true });
|
||||
|
||||
console.log('VLC extracted to', destDir);
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
function log(message) {
|
||||
console.log(` - ${message}`);
|
||||
}
|
||||
|
||||
function createLoaderScript({ executableName, productName }) {
|
||||
return `#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
UNPRIVILEGED_USERNS_ENABLED=$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null)
|
||||
RESTRICT_UNPRIVILEGED_USERNS=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)
|
||||
|
||||
SCRIPT_PATH="\${BASH_SOURCE[0]}"
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
RESOLVED_SCRIPT_PATH=$(readlink -f "$SCRIPT_PATH" 2>/dev/null || true)
|
||||
if [ -n "$RESOLVED_SCRIPT_PATH" ]; then
|
||||
SCRIPT_PATH="$RESOLVED_SCRIPT_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||
|
||||
APPLY_NO_SANDBOX_FLAG=0
|
||||
if [ "$UNPRIVILEGED_USERNS_ENABLED" != 1 ] || [ "$RESTRICT_UNPRIVILEGED_USERNS" = 1 ]; then
|
||||
APPLY_NO_SANDBOX_FLAG=1
|
||||
fi
|
||||
|
||||
if [ "$SCRIPT_DIR" = "/usr/bin" ]; then
|
||||
SCRIPT_DIR="/opt/${productName}"
|
||||
fi
|
||||
|
||||
EXEC_ARGS=()
|
||||
if [ "$APPLY_NO_SANDBOX_FLAG" = 1 ]; then
|
||||
echo "Note: Running with --no-sandbox since unprivileged_userns_clone is disabled or apparmor_restrict_unprivileged_userns is enabled."
|
||||
EXEC_ARGS+=(--no-sandbox)
|
||||
fi
|
||||
|
||||
exec "$SCRIPT_DIR/${executableName}.bin" "\${EXEC_ARGS[@]}" "$@"
|
||||
`;
|
||||
}
|
||||
|
||||
async function afterPackHook(params) {
|
||||
if (params.electronPlatformName !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
log('applying Linux launcher sandbox fix');
|
||||
|
||||
const executable = path.join(params.appOutDir, params.packager.executableName);
|
||||
|
||||
try {
|
||||
await fs.rename(executable, `${executable}.bin`);
|
||||
await fs.writeFile(
|
||||
executable,
|
||||
createLoaderScript({
|
||||
executableName: params.packager.executableName,
|
||||
productName: params.packager.appInfo.productName,
|
||||
})
|
||||
);
|
||||
await fs.chmod(executable, 0o755);
|
||||
} catch (error) {
|
||||
log(`failed to create launcher wrapper: ${error.message}`);
|
||||
throw new Error('Failed to create launcher wrapper');
|
||||
}
|
||||
|
||||
log('Linux launcher sandbox fix applied');
|
||||
}
|
||||
|
||||
module.exports = afterPackHook;
|
||||
module.exports.createLoaderScript = createLoaderScript;
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build KiraTV and package .deb + AppImage for the current machine architecture.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = path.resolve(__dirname, '..', '..');
|
||||
const configPath = path.join(workspaceRoot, 'electron-builder.json');
|
||||
|
||||
function linuxArchFromNode() {
|
||||
switch (process.arch) {
|
||||
case 'arm64':
|
||||
return 'arm64';
|
||||
case 'arm':
|
||||
return 'armv7l';
|
||||
default:
|
||||
return 'x64';
|
||||
}
|
||||
}
|
||||
|
||||
const original = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(original);
|
||||
|
||||
const arch = linuxArchFromNode();
|
||||
config.linux = config.linux ?? {};
|
||||
config.linux.target = [
|
||||
{ target: 'deb', arch: [arch] },
|
||||
{ target: 'AppImage', arch: [arch] },
|
||||
];
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
|
||||
|
||||
const run = (cmd) => {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: workspaceRoot, env: process.env });
|
||||
};
|
||||
|
||||
try {
|
||||
const nx = path.join(workspaceRoot, 'node_modules', '.bin', 'nx');
|
||||
const nxCmd = fs.existsSync(nx) ? `"${nx}"` : 'npx nx';
|
||||
run(`${nxCmd} run electron-backend:build --configuration=kiratv`);
|
||||
run(`${nxCmd} run electron-backend:make --configuration=kiratv`);
|
||||
|
||||
console.log(
|
||||
'\nPaquetes generados en dist/executables: .deb y .AppImage (kiratv-*-linux-*).\n'
|
||||
);
|
||||
} finally {
|
||||
fs.writeFileSync(configPath, original);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build KiraTV and package only a .deb for the current machine architecture.
|
||||
* Temporarily narrows electron-builder linux targets, then restores config.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = path.resolve(__dirname, '..', '..');
|
||||
const configPath = path.join(workspaceRoot, 'electron-builder.json');
|
||||
|
||||
function debArchFromNode() {
|
||||
switch (process.arch) {
|
||||
case 'arm64':
|
||||
return 'arm64';
|
||||
case 'arm':
|
||||
return 'armv7l';
|
||||
default:
|
||||
return 'x64';
|
||||
}
|
||||
}
|
||||
|
||||
const original = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(original);
|
||||
|
||||
const arch = debArchFromNode();
|
||||
config.linux = config.linux ?? {};
|
||||
config.linux.target = [{ target: 'deb', arch: [arch] }];
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
|
||||
|
||||
const run = (cmd) => {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: workspaceRoot, env: process.env });
|
||||
};
|
||||
|
||||
try {
|
||||
const nx = path.join(workspaceRoot, 'node_modules', '.bin', 'nx');
|
||||
const nxCmd = fs.existsSync(nx) ? `"${nx}"` : 'npx nx';
|
||||
run(`${nxCmd} run electron-backend:build --configuration=kiratv`);
|
||||
run(`${nxCmd} run electron-backend:make --configuration=kiratv`);
|
||||
|
||||
console.log(
|
||||
'\nPaquete .deb generado en dist/executables (busca kiratv-*-linux-*.deb).\n'
|
||||
);
|
||||
} finally {
|
||||
fs.writeFileSync(configPath, original);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Linux: .deb + AppImage (arquitectura actual).
|
||||
* Windows: solo si process.platform === 'win32' → NSIS x64 con VLC embebido.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
|
||||
function run(relScript) {
|
||||
const p = path.join(root, relScript);
|
||||
execSync(`node "${p}"`, { stdio: 'inherit', cwd: root, env: process.env });
|
||||
}
|
||||
|
||||
run('tools/packaging/make-kiratv-linux-deb-appimage.mjs');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
run('tools/packaging/make-kiratv-windows-nsis.mjs');
|
||||
} else {
|
||||
console.log(
|
||||
'\n[Windows] Omitido en este SO. En un PC Windows ejecuta:\n' +
|
||||
' pnpm run make:kiratv:windows-nsis\n'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Windows only: download bundled VLC, patch electron-builder to ship resources/vlc,
|
||||
* then build KiraTV NSIS installer (x64).
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
console.error(
|
||||
'Este script debe ejecutarse en Windows (Node win32).\n' +
|
||||
'Para generar el .exe con VLC embebido, usa una máquina Windows o un job windows-latest en CI.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = path.resolve(__dirname, '..', '..');
|
||||
const configPath = path.join(workspaceRoot, 'electron-builder.json');
|
||||
|
||||
execSync(`node "${path.join(__dirname, 'download-vlc-windows.mjs')}"`, {
|
||||
stdio: 'inherit',
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
|
||||
const vlcExe = path.join(
|
||||
workspaceRoot,
|
||||
'tools',
|
||||
'packaging',
|
||||
'bundled-vlc',
|
||||
'win64',
|
||||
'vlc.exe'
|
||||
);
|
||||
if (!fs.existsSync(vlcExe)) {
|
||||
console.error('Missing bundled VLC after download:', vlcExe);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const original = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(original);
|
||||
|
||||
const baseExtra = Array.isArray(config.extraResources)
|
||||
? config.extraResources
|
||||
: config.extraResources
|
||||
? [config.extraResources]
|
||||
: [];
|
||||
|
||||
config.extraResources = [
|
||||
...baseExtra.filter((e) => {
|
||||
if (typeof e === 'string') {
|
||||
return !String(e).includes('bundled-vlc');
|
||||
}
|
||||
if (e && typeof e === 'object' && e.to === 'vlc') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
{
|
||||
from: 'tools/packaging/bundled-vlc/win64',
|
||||
to: 'vlc',
|
||||
filter: ['**/*'],
|
||||
},
|
||||
];
|
||||
|
||||
config.win = config.win ?? {};
|
||||
config.win.target = [{ target: 'nsis', arch: ['x64'] }];
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
|
||||
|
||||
const run = (cmd) => {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: workspaceRoot, env: process.env });
|
||||
};
|
||||
|
||||
try {
|
||||
const nx = path.join(workspaceRoot, 'node_modules', '.bin', 'nx');
|
||||
const nxCmd = fs.existsSync(nx) ? `"${nx}"` : 'npx nx';
|
||||
run(`${nxCmd} run electron-backend:build --configuration=kiratv`);
|
||||
// On Windows, Nx defaults to the Windows platform. Do not pass --platform/--arch:
|
||||
// Nx forwards them to dependency targets (web, remote-control-web) and breaks.
|
||||
run(`${nxCmd} run electron-backend:make --configuration=kiratv`);
|
||||
|
||||
console.log(
|
||||
'\nInstalador NSIS generado en dist/executables (kiratv-*-windows-x64-setup.exe) con VLC en resources/vlc.\n'
|
||||
);
|
||||
} finally {
|
||||
fs.writeFileSync(configPath, original);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
|
||||
const [platform, arch = ''] = normalizedArgs;
|
||||
|
||||
if (!platform) {
|
||||
console.error(
|
||||
'Usage: node tools/packaging/verify-electron-package-layout.mjs <macos|linux|windows> [arch]'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const executablesRoot = path.join(workspaceRoot, 'dist', 'executables');
|
||||
const packageJsonPath = path.join(workspaceRoot, 'package.json');
|
||||
const electronBuilderConfigPath = path.join(
|
||||
workspaceRoot,
|
||||
'electron-builder.json'
|
||||
);
|
||||
const builderEffectiveConfigPath = path.join(
|
||||
executablesRoot,
|
||||
'builder-effective-config.yaml'
|
||||
);
|
||||
const flatpakMetainfoPath = path.join(
|
||||
workspaceRoot,
|
||||
'apps',
|
||||
'electron-backend',
|
||||
'linux',
|
||||
'com.kiracloud.kiratv.metainfo.xml'
|
||||
);
|
||||
const packageMetadata = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const electronBuilderConfig = JSON.parse(
|
||||
fs.readFileSync(electronBuilderConfigPath, 'utf8')
|
||||
);
|
||||
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
|
||||
const snapConfigInspection = loadSnapConfigInspection();
|
||||
const workerRelativeDir = path.join(
|
||||
'dist',
|
||||
'apps',
|
||||
'electron-backend',
|
||||
'workers'
|
||||
);
|
||||
const workerFiles = ['epg-parser.worker.js', 'database.worker.js'];
|
||||
const nativeModuleRelativeDirs = [
|
||||
path.join('app.asar.unpacked', 'node_modules'),
|
||||
path.join('app.asar.unpacked', 'electron-backend', 'node_modules'),
|
||||
path.join(
|
||||
'app.asar.unpacked',
|
||||
'dist',
|
||||
'apps',
|
||||
'electron-backend',
|
||||
'node_modules'
|
||||
),
|
||||
];
|
||||
const linuxExecutableName = getLinuxExecutableName();
|
||||
|
||||
function directoryExists(directoryPath) {
|
||||
return (
|
||||
fs.existsSync(directoryPath) && fs.statSync(directoryPath).isDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
function fileExists(filePath) {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
}
|
||||
|
||||
function getMacResourceDirs() {
|
||||
const candidates = [
|
||||
{
|
||||
arch: 'x64',
|
||||
directory: path.join(
|
||||
executablesRoot,
|
||||
'mac',
|
||||
'KiraTV.app',
|
||||
'Contents',
|
||||
'Resources'
|
||||
),
|
||||
},
|
||||
{
|
||||
arch: 'arm64',
|
||||
directory: path.join(
|
||||
executablesRoot,
|
||||
'mac-arm64',
|
||||
'KiraTV.app',
|
||||
'Contents',
|
||||
'Resources'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return candidates
|
||||
.filter((candidate) => !arch || candidate.arch === arch)
|
||||
.map((candidate) => candidate.directory)
|
||||
.filter(directoryExists);
|
||||
}
|
||||
|
||||
function getUnpackedResourceDirs(prefix) {
|
||||
if (!directoryExists(executablesRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(executablesRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith(prefix) &&
|
||||
entry.name.endsWith('-unpacked')
|
||||
)
|
||||
.map((entry) => path.join(executablesRoot, entry.name, 'resources'))
|
||||
.filter(directoryExists);
|
||||
}
|
||||
|
||||
function getResourceDirs() {
|
||||
switch (platform) {
|
||||
case 'macos':
|
||||
return getMacResourceDirs();
|
||||
case 'linux':
|
||||
return getUnpackedResourceDirs('linux');
|
||||
case 'windows':
|
||||
return getUnpackedResourceDirs('win');
|
||||
default:
|
||||
console.error(`Unsupported platform "${platform}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeExecutableName(value) {
|
||||
return value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '');
|
||||
}
|
||||
|
||||
function getLinuxExecutableName() {
|
||||
const configuredExecutableName =
|
||||
electronBuilderConfig.linux?.executableName ??
|
||||
electronBuilderConfig.executableName;
|
||||
|
||||
if (configuredExecutableName) {
|
||||
return sanitizeExecutableName(configuredExecutableName);
|
||||
}
|
||||
|
||||
return packageMetadata.name.toLowerCase();
|
||||
}
|
||||
|
||||
function parseYamlScalar(value) {
|
||||
const normalizedValue = value.trim();
|
||||
|
||||
if (normalizedValue === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedValue === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedValue === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const singleQuotedMatch = normalizedValue.match(/^'(.*)'$/);
|
||||
if (singleQuotedMatch) {
|
||||
return singleQuotedMatch[1].replace(/''/g, "'");
|
||||
}
|
||||
|
||||
const doubleQuotedMatch = normalizedValue.match(/^"(.*)"$/);
|
||||
if (doubleQuotedMatch) {
|
||||
return doubleQuotedMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function getYamlSectionLines(yamlContent, sectionName) {
|
||||
const lines = yamlContent.split(/\r?\n/);
|
||||
const sectionHeader = `${sectionName}:`;
|
||||
const sectionLines = [];
|
||||
let isInsideSection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!isInsideSection) {
|
||||
if (line === sectionHeader) {
|
||||
isInsideSection = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
sectionLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const indentation = line.match(/^ */)?.[0].length ?? 0;
|
||||
if (indentation === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
sectionLines.push(line);
|
||||
}
|
||||
|
||||
return isInsideSection ? sectionLines : null;
|
||||
}
|
||||
|
||||
function parseIndentedYamlSequence(lines, startIndex, indentation) {
|
||||
const values = [];
|
||||
let nextIndex = startIndex;
|
||||
|
||||
while (nextIndex < lines.length) {
|
||||
const line = lines[nextIndex];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine === '') {
|
||||
nextIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentIndentation = line.match(/^ */)?.[0].length ?? 0;
|
||||
if (currentIndentation < indentation) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentIndentation > indentation || !trimmedLine.startsWith('- ')) {
|
||||
break;
|
||||
}
|
||||
|
||||
values.push(parseYamlScalar(trimmedLine.slice(2)));
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseIndentedYamlMapping(lines, startIndex, indentation) {
|
||||
const values = {};
|
||||
let nextIndex = startIndex;
|
||||
|
||||
while (nextIndex < lines.length) {
|
||||
const line = lines[nextIndex];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine === '') {
|
||||
nextIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentIndentation = line.match(/^ */)?.[0].length ?? 0;
|
||||
if (currentIndentation < indentation) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentIndentation > indentation) {
|
||||
break;
|
||||
}
|
||||
|
||||
const separatorIndex = trimmedLine.indexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const key = trimmedLine.slice(0, separatorIndex).trim();
|
||||
const rawValue = trimmedLine.slice(separatorIndex + 1).trim();
|
||||
values[key] = parseYamlScalar(rawValue);
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEffectiveSnapConfig(yamlContent) {
|
||||
const sectionLines = getYamlSectionLines(yamlContent, 'snap');
|
||||
|
||||
if (!sectionLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapConfig = {};
|
||||
|
||||
for (let index = 0; index < sectionLines.length; index += 1) {
|
||||
const line = sectionLines[index];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indentation = line.match(/^ */)?.[0].length ?? 0;
|
||||
if (indentation !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = trimmedLine.indexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmedLine.slice(0, separatorIndex).trim();
|
||||
const rawValue = trimmedLine.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (rawValue !== '') {
|
||||
snapConfig[key] = parseYamlScalar(rawValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'executableArgs') {
|
||||
const { values, nextIndex } = parseIndentedYamlSequence(
|
||||
sectionLines,
|
||||
index + 1,
|
||||
4
|
||||
);
|
||||
snapConfig[key] = values;
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'environment') {
|
||||
const { values, nextIndex } = parseIndentedYamlMapping(
|
||||
sectionLines,
|
||||
index + 1,
|
||||
4
|
||||
);
|
||||
snapConfig[key] = values;
|
||||
index = nextIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return snapConfig;
|
||||
}
|
||||
|
||||
function loadSnapConfigInspection() {
|
||||
if (fileExists(builderEffectiveConfigPath)) {
|
||||
const effectiveConfigContent = fs.readFileSync(
|
||||
builderEffectiveConfigPath,
|
||||
'utf8'
|
||||
);
|
||||
const effectiveSnapConfig = parseEffectiveSnapConfig(
|
||||
effectiveConfigContent
|
||||
);
|
||||
|
||||
if (effectiveSnapConfig) {
|
||||
return {
|
||||
config: effectiveSnapConfig,
|
||||
sourcePath: builderEffectiveConfigPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: electronBuilderConfig.snap ?? {},
|
||||
sourcePath: electronBuilderConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
function verifyLinuxLauncher(resourceDir, errors) {
|
||||
const appDir = path.dirname(resourceDir);
|
||||
const launcherPath = path.join(appDir, linuxExecutableName);
|
||||
const launcherBinaryPath = `${launcherPath}.bin`;
|
||||
|
||||
if (!fileExists(launcherBinaryPath)) {
|
||||
errors.push(
|
||||
`Missing Linux launcher binary in ${appDir}: ${path.basename(launcherBinaryPath)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileExists(launcherPath)) {
|
||||
errors.push(
|
||||
`Missing Linux launcher wrapper in ${appDir}: ${path.basename(launcherPath)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const launcherScript = fs.readFileSync(launcherPath, 'utf8');
|
||||
const requiredMarkers = [
|
||||
'SCRIPT_PATH="${BASH_SOURCE[0]}"',
|
||||
'readlink -f "$SCRIPT_PATH"',
|
||||
`exec "$SCRIPT_DIR/${linuxExecutableName}.bin"`,
|
||||
];
|
||||
const missingMarkers = requiredMarkers.filter(
|
||||
(marker) => !launcherScript.includes(marker)
|
||||
);
|
||||
|
||||
if (missingMarkers.length > 0) {
|
||||
errors.push(
|
||||
[
|
||||
`Linux launcher wrapper is missing symlink-safe logic in ${launcherPath}.`,
|
||||
'Missing markers:',
|
||||
...missingMarkers.map((marker) => `- ${marker}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyFlatpakPermissions(errors) {
|
||||
if (!Array.isArray(flatpakFinishArgs)) {
|
||||
errors.push('Flatpak finishArgs must be configured as an array.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flatpakFinishArgs.includes('--talk-name=org.freedesktop.Flatpak')) {
|
||||
errors.push(
|
||||
'Flatpak finishArgs must include --talk-name=org.freedesktop.Flatpak for host player launching.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySnapPackagingConfig(errors) {
|
||||
if (snapConfigInspection.config?.base !== 'core22') {
|
||||
errors.push(
|
||||
`Snap config in ${snapConfigInspection.sourcePath} must set base to core22 so packaged native modules stay compatible with the Snap runtime glibc.`
|
||||
);
|
||||
}
|
||||
|
||||
const snapExecutableArgs = Array.isArray(
|
||||
snapConfigInspection.config?.executableArgs
|
||||
)
|
||||
? snapConfigInspection.config.executableArgs
|
||||
: [];
|
||||
|
||||
if (!snapExecutableArgs.includes('--ozone-platform=x11')) {
|
||||
errors.push(
|
||||
`Snap config in ${snapConfigInspection.sourcePath} must include --ozone-platform=x11 in executableArgs.`
|
||||
);
|
||||
}
|
||||
|
||||
if (snapConfigInspection.config?.allowNativeWayland === true) {
|
||||
errors.push(
|
||||
`Snap config in ${snapConfigInspection.sourcePath} must not enable allowNativeWayland while the X11 startup workaround is required.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyResourceDir(resourceDir) {
|
||||
const missingWorkers = workerFiles.filter(
|
||||
(workerFile) =>
|
||||
!fileExists(path.join(resourceDir, workerRelativeDir, workerFile))
|
||||
);
|
||||
|
||||
const nativeModuleDirs = nativeModuleRelativeDirs.map((relativeDir) =>
|
||||
path.join(resourceDir, relativeDir)
|
||||
);
|
||||
const matchingNativeModuleDir = nativeModuleDirs.find((nativeDir) =>
|
||||
fileExists(path.join(nativeDir, 'better-sqlite3', 'package.json'))
|
||||
);
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (missingWorkers.length > 0) {
|
||||
errors.push(
|
||||
`Missing worker artifacts in ${resourceDir}: ${missingWorkers.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!matchingNativeModuleDir) {
|
||||
errors.push(
|
||||
[
|
||||
`Unable to find unpacked better-sqlite3 in ${resourceDir}.`,
|
||||
'Checked:',
|
||||
...nativeModuleDirs.map((nativeDir) => `- ${nativeDir}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (!fileExists(flatpakMetainfoPath)) {
|
||||
errors.push(
|
||||
`Missing Flatpak metainfo file: ${flatpakMetainfoPath}`
|
||||
);
|
||||
}
|
||||
verifyFlatpakPermissions(errors);
|
||||
verifySnapPackagingConfig(errors);
|
||||
verifyLinuxLauncher(resourceDir, errors);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceDir,
|
||||
errors,
|
||||
matchingNativeModuleDir,
|
||||
};
|
||||
}
|
||||
|
||||
const resourceDirs = getResourceDirs();
|
||||
|
||||
if (resourceDirs.length === 0) {
|
||||
console.error(
|
||||
`No packaged resource directories found for ${platform}${arch ? ` (${arch})` : ''}.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = resourceDirs.map(verifyResourceDir);
|
||||
const failures = results.filter((result) => result.errors.length > 0);
|
||||
|
||||
for (const result of results) {
|
||||
console.log(`Verified packaged resources: ${result.resourceDir}`);
|
||||
if (result.matchingNativeModuleDir) {
|
||||
console.log(
|
||||
`Resolved unpacked better-sqlite3 at: ${result.matchingNativeModuleDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(failure.errors.join('\n'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,759 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
listMarketingArtworkFixtures,
|
||||
type MarketingArtworkFixture,
|
||||
} from '../../apps/xtream-mock-server/src/app/generators/marketing.generator.js';
|
||||
|
||||
type ArtworkKind = 'backdrop' | 'poster';
|
||||
type ArtworkQuality = 'low' | 'medium' | 'high' | 'auto';
|
||||
type RunMode = 'dry-run' | 'generate' | 'manifest' | 'validate';
|
||||
|
||||
type ArtworkJob = {
|
||||
contentType: 'movie' | 'series';
|
||||
filename: string;
|
||||
kind: ArtworkKind;
|
||||
outputPath: string;
|
||||
prompt: string;
|
||||
quality: ArtworkQuality;
|
||||
size: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ArtworkManifest = {
|
||||
assetRoot: string;
|
||||
assets: Array<{
|
||||
contentType: 'movie' | 'series';
|
||||
filename: string;
|
||||
kind: ArtworkKind;
|
||||
manualApprovalStatus: 'pending-human-review';
|
||||
model: string;
|
||||
prompt: string;
|
||||
quality: ArtworkQuality;
|
||||
size: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
}>;
|
||||
model: string;
|
||||
outputFormat: 'png';
|
||||
version: 1;
|
||||
};
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const assetRoot = path.join(
|
||||
workspaceRoot,
|
||||
'apps/xtream-mock-server/public/marketing'
|
||||
);
|
||||
const manifestPath = path.join(assetRoot, 'manifest.json');
|
||||
const relativeAssetRoot = 'apps/xtream-mock-server/public/marketing';
|
||||
const defaultModel = 'gpt-image-2';
|
||||
const outputFormat = 'png';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const mode = getMode(args);
|
||||
const model = getStringArg('--model') ?? process.env['OPENAI_IMAGE_MODEL'] ?? defaultModel;
|
||||
const quality = getQualityArg('--quality') ?? getQuality(process.env['OPENAI_IMAGE_QUALITY']) ?? 'medium';
|
||||
const force = args.includes('--force');
|
||||
const limit = getNumberArg('--limit');
|
||||
const delayMs = getNumberArg('--delay-ms') ?? 0;
|
||||
|
||||
const allJobs = buildArtworkJobs(model, quality);
|
||||
const jobs = allJobs.slice(0, limit ?? undefined);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (mode === 'dry-run') {
|
||||
printDryRun();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureOutputDirs();
|
||||
writeManifest(model, allJobs);
|
||||
|
||||
if (mode === 'manifest') {
|
||||
console.log(`Wrote artwork manifest: ${relativePath(manifestPath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'generate') {
|
||||
await generateMissingArtwork();
|
||||
}
|
||||
|
||||
await validateArtwork(jobs);
|
||||
}
|
||||
|
||||
function buildArtworkJobs(
|
||||
selectedModel: string,
|
||||
selectedQuality: ArtworkQuality
|
||||
): ArtworkJob[] {
|
||||
return listMarketingArtworkFixtures().flatMap((fixture) =>
|
||||
(['poster', 'backdrop'] as const).map((kind) => {
|
||||
const filename = `${kind}/${fixture.slug}.png`;
|
||||
return {
|
||||
contentType: fixture.contentType,
|
||||
filename,
|
||||
kind,
|
||||
outputPath: path.join(assetRoot, filename),
|
||||
prompt: buildPrompt(fixture, kind),
|
||||
quality: selectedQuality,
|
||||
size: kind === 'poster' ? '1024x1536' : '1536x864',
|
||||
slug: fixture.slug,
|
||||
title: fixture.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrompt(fixture: MarketingArtworkFixture, kind: ArtworkKind): string {
|
||||
const profile = promptProfileForTitle(fixture.name);
|
||||
const direction = visualDirectionForTitle(fixture.name);
|
||||
const shared = [
|
||||
`Create original fictional demo artwork for IPTVnator release screenshots.`,
|
||||
`Fictional ${fixture.contentType}: "${fixture.name}" (${fixture.year}).`,
|
||||
`Genre: ${fixture.genre}.`,
|
||||
`Story seed: ${fixture.tagline} ${fixture.description}`,
|
||||
`Visual direction: ${direction}`,
|
||||
`Visual medium and style: ${profile.style}`,
|
||||
`Palette and texture: ${profile.palette}`,
|
||||
[
|
||||
'Safety and originality constraints:',
|
||||
'Do not depict or reference any real movie, series, franchise, celebrity, actor likeness, branded character, copyrighted logo, trademarked costume, existing poster composition, or readable brand mark.',
|
||||
'No watermarks, no studio logos, no credits, no title typography, and no legible text.',
|
||||
].join(' '),
|
||||
];
|
||||
|
||||
if (kind === 'poster') {
|
||||
return [
|
||||
'Use case: ads-marketing',
|
||||
'Asset type: portrait cover/poster PNG for a fictional streaming demo catalog.',
|
||||
...shared,
|
||||
`Composition: portrait 2:3 ${profile.posterComposition}; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
'Use case: ads-marketing',
|
||||
'Asset type: landscape backdrop PNG for a fictional streaming demo details screen.',
|
||||
...shared,
|
||||
`Composition: wide 16:9 ${profile.backdropComposition}; darker edges and open negative space so app UI overlays remain readable; no text or logos.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
type PromptProfile = {
|
||||
backdropComposition: string;
|
||||
palette: string;
|
||||
posterComposition: string;
|
||||
style: string;
|
||||
};
|
||||
|
||||
function promptProfileForTitle(title: string): PromptProfile {
|
||||
const profiles: Record<string, PromptProfile> = {
|
||||
'Crimson Skylark': {
|
||||
style: 'golden-hour aerial adventure photography with realistic rescue-drama production design, wind, clouds, practical flight gear, and natural lens flare',
|
||||
palette:
|
||||
'sunrise coral, storm gray, aviation green, grainy but realistic atmospheric haze',
|
||||
posterComposition:
|
||||
'single rescue courier against a huge storm sky, diagonal flight path, naturalistic poster photography',
|
||||
backdropComposition:
|
||||
'vast sky route above a modern city, layered storm clouds, small courier silhouette, breathable horizon',
|
||||
},
|
||||
'The Voltage Guard': {
|
||||
style: 'gritty disaster-thriller photography, rain-soaked infrastructure, documentary realism, sparks, emergency work lights, no fantasy costumes',
|
||||
palette:
|
||||
'steel blue, sodium orange, wet asphalt black, white electrical flares',
|
||||
posterComposition:
|
||||
'workers and rail geometry forming a tense action triangle, practical uniforms and tools',
|
||||
backdropComposition:
|
||||
'wide elevated transit grid under unstable weather, workers small in frame, strong industrial depth',
|
||||
},
|
||||
'Midnight Mantle': {
|
||||
style: 'gothic noir illustration with theatrical shadow shapes, old print texture, rain, velvet blacks, and painterly mystery atmosphere',
|
||||
palette:
|
||||
'deep violet, amber marquee light, wet black streets, muted crimson accents',
|
||||
posterComposition:
|
||||
'theatre silhouette and sweeping cloak as an abstract mystery shape, not a superhero pose',
|
||||
backdropComposition:
|
||||
'empty theatre district at night, marquee glow without readable letters, cloak-like shadow crossing rooftops',
|
||||
},
|
||||
'Quantum Comet': {
|
||||
style: 'hard science-fiction concept art with crisp spacecraft detail, star charts, comet ice, scale diagrams, and luminous navigation UI shapes without readable text',
|
||||
palette:
|
||||
'icy cyan, white comet dust, deep navy, precise magenta navigation glows',
|
||||
posterComposition:
|
||||
'cartographer figure and fractured comet arc arranged like a scientific expedition poster',
|
||||
backdropComposition:
|
||||
'wide comet trail and distant armada silhouettes, clean negative space around the center',
|
||||
},
|
||||
'Orbit of Atlas': {
|
||||
style: 'epic cosmic survival painting with mineral surfaces, colony lights, gravitational dust ribbons, and solemn scale',
|
||||
palette:
|
||||
'burnt gold, moon gray, dusty umber, cold blue space shadows',
|
||||
posterComposition:
|
||||
'small rescue pilot dwarfed by a cracked moon, monumental survival mood',
|
||||
backdropComposition:
|
||||
'fractured moon over a remote colony, dust and sunlight bending in wide arcs',
|
||||
},
|
||||
'Nova Relay': {
|
||||
style: 'sleek space-opera animation art with clean silhouettes, radiant motion trails, glossy star-bridge architecture, and optimistic color',
|
||||
palette:
|
||||
'electric teal, royal blue, white light trails, warm planetary glow',
|
||||
posterComposition:
|
||||
'twin runners crossing a luminous bridge with strong graphic diagonals',
|
||||
backdropComposition:
|
||||
'star bridge leading toward a blue planet, two tiny figures and sweeping light messages',
|
||||
},
|
||||
'Beacon Boy and the Clockwork Garden': {
|
||||
style: 'storybook 3D animated fantasy, handmade brass mechanisms, moss, warm lantern light, rounded forms, and tactile toy-like detail',
|
||||
palette:
|
||||
'moss green, brass gold, warm amber, soft twilight blue',
|
||||
posterComposition:
|
||||
'young inventor, grandmother, and clockwork garden guardian in a warm discovery tableau',
|
||||
backdropComposition:
|
||||
'hidden underground garden with gears, vines, lanterns, and gentle mechanical guardians',
|
||||
},
|
||||
'The Invisible Saturday': {
|
||||
style: 'bright claymation-inspired family comedy, miniature fairground sets, visible handmade textures, playful invisible-person gags',
|
||||
palette:
|
||||
'cotton-candy pink, sunny yellow, grass green, soft sky blue',
|
||||
posterComposition:
|
||||
'floating ribbons, footprints, and balloons implying invisible siblings at a fair',
|
||||
backdropComposition:
|
||||
'wide neighborhood fair scene with playful impossible traces and generous open sky',
|
||||
},
|
||||
'Captain Kindling': {
|
||||
style: 'warm hand-painted camping comedy illustration, cozy forest dusk, ember glow, imperfect brush texture, gentle humor',
|
||||
palette:
|
||||
'campfire orange, pine green, twilight purple, warm tan canvas',
|
||||
posterComposition:
|
||||
'camp counselor spark and misfit camper silhouettes around a glowing clearing',
|
||||
backdropComposition:
|
||||
'wide forest camp at dusk with cabin lights, ember trails, and friendly wilderness depth',
|
||||
},
|
||||
'Masks of Tomorrow': {
|
||||
style: 'fashion-documentary editorial photography, fabric tables, workshop tools, mirrors, fittings, and soft daylight realism',
|
||||
palette:
|
||||
'cream fabric, graphite tools, tomato red thread, daylight blue shadows',
|
||||
posterComposition:
|
||||
'hands, mannequins, and unfinished masks arranged as an editorial magazine cover image',
|
||||
backdropComposition:
|
||||
'busy workshop with textiles and signal equipment, human craft details, no logo marks',
|
||||
},
|
||||
'Signal Tower Seven': {
|
||||
style: 'retro 1970s disaster-drama poster art, analog radio equipment, snowy mountain rescue maps, screen-print texture',
|
||||
palette:
|
||||
'faded mustard, radio-room brown, emergency red, winter blue',
|
||||
posterComposition:
|
||||
'radio operator profile, signal tower, and storm map shapes in vintage rescue-drama layout',
|
||||
backdropComposition:
|
||||
'wide analog signal room overlooking winter mountains and storm lights',
|
||||
},
|
||||
'The League of Lanterns': {
|
||||
style: 'human-interest documentary photography with natural faces, handheld lanterns, dark streets, and hopeful realism',
|
||||
palette:
|
||||
'blackout navy, lantern gold, brick red, soft skin tones',
|
||||
posterComposition:
|
||||
'volunteers carrying lanterns through darkness, intimate eye-level composition',
|
||||
backdropComposition:
|
||||
'wide city block during blackout with lantern paths and community silhouettes',
|
||||
},
|
||||
'Paper Moon Parade': {
|
||||
style: 'simple flat paper-cut illustration for children, large clean shapes, visible paper grain, gentle handmade collage, no realistic rendering',
|
||||
palette:
|
||||
'midnight blue, butter yellow, pale lavender, soft coral, warm paper white',
|
||||
posterComposition:
|
||||
'big smiling paper moon, tiny town silhouettes, parade shapes, lots of clean negative space',
|
||||
backdropComposition:
|
||||
'wide paper-cut town street under a rolling moon with simple parade banners and sparse stars',
|
||||
},
|
||||
'Dot and the Cloud Train': {
|
||||
style: 'ultra-simple preschool cartoon, rounded vector shapes, soft gradients, thick friendly outlines, very low detail',
|
||||
palette:
|
||||
'sky blue, marshmallow white, lemon yellow, strawberry pink, mint green',
|
||||
posterComposition:
|
||||
'small dot character stepping onto a rounded cloud train, clear simple shapes for young kids',
|
||||
backdropComposition:
|
||||
'wide pastel sky with a cloud train crossing simple weather-color stops',
|
||||
},
|
||||
'Tiny Planet Picnic': {
|
||||
style: 'minimal flat family sci-fi cartoon, clean vector-like shapes, playful scale, sparse details, soft geometric planets',
|
||||
palette:
|
||||
'peach, teal, lilac, sunny yellow, deep space blue used sparingly',
|
||||
posterComposition:
|
||||
'three children on a tiny round planet with picnic blanket and orbiting flowers',
|
||||
backdropComposition:
|
||||
'wide tiny planet landscape with flowers orbiting like satellites and open simple sky',
|
||||
},
|
||||
'The Quiet Aquarium': {
|
||||
style: 'abstract meditative poster art, flat translucent shapes, slow gradients, simplified fish silhouettes, calm museum-like composition',
|
||||
palette:
|
||||
'deep sea blue, glass cyan, muted violet, soft black, small coral accents',
|
||||
posterComposition:
|
||||
'large abstract aquarium window with drifting shapes and one quiet visitor silhouette',
|
||||
backdropComposition:
|
||||
'wide closed aquarium interior with glowing tanks, drifting abstract fish shapes, and quiet negative space',
|
||||
},
|
||||
'Square City Sonata': {
|
||||
style: 'Bauhaus-inspired abstract musical city art, flat geometric blocks, no perspective realism, rhythmic rectangles and circles',
|
||||
palette:
|
||||
'cream, brick red, cobalt blue, mustard yellow, charcoal',
|
||||
posterComposition:
|
||||
'stacked city blocks, windows, and circles arranged like a musical score',
|
||||
backdropComposition:
|
||||
'wide geometric city grid with crosswalk rhythms and streetlight circles across a flat afternoon',
|
||||
},
|
||||
'Lanterns Under Snow': {
|
||||
style: 'minimal winter folk illustration, flat shapes, quiet negative space, soft paper texture, restrained detail',
|
||||
palette:
|
||||
'snow white, ink navy, lantern amber, muted pine green, pale gray',
|
||||
posterComposition:
|
||||
'single warm lantern path climbing a snowy hill town with small simple figures',
|
||||
backdropComposition:
|
||||
'wide snowy hillside town with sparse houses, paper lantern dots, and a calm empty sky',
|
||||
},
|
||||
'Skyline Sentinels': {
|
||||
style: 'urban infrastructure drama photography, maintenance crew realism, rooftops, rails, bridge cables, sunrise work shift mood',
|
||||
palette:
|
||||
'concrete gray, safety yellow, sunrise peach, river blue',
|
||||
posterComposition:
|
||||
'young maintenance crew on a roof with tools and city geometry, not heroic costumes',
|
||||
backdropComposition:
|
||||
'wide rooftop and bridge panorama with work lights and morning haze',
|
||||
},
|
||||
'The Aegis Club': {
|
||||
style: 'warm workplace dramedy photography, neighborhood social club interiors, old wood, mismatched chairs, trophies, candid ensemble energy',
|
||||
palette:
|
||||
'walnut brown, faded green upholstery, brass lamps, soft cream walls',
|
||||
posterComposition:
|
||||
'group around an old club table with lived-in props and gentle humor',
|
||||
backdropComposition:
|
||||
'wide social club room with training area, memorabilia, and warm lamps',
|
||||
},
|
||||
'Starfall Academy': {
|
||||
style: 'colorful anime-inspired space-school illustration with expressive cadets, school-ship corridors, science experiments, and star windows',
|
||||
palette:
|
||||
'bubblegum pink, starship white, asteroid gray, neon mint',
|
||||
posterComposition:
|
||||
'cadets and floating school equipment in dynamic anime school-club composition',
|
||||
backdropComposition:
|
||||
'wide school-ship classroom near asteroid belt with desks, windows, and drifting experiment lights',
|
||||
},
|
||||
'Tomorrow Bureau': {
|
||||
style: 'minimalist bureaucratic sci-fi thriller, glass offices, files, clocks, reflection grids, restrained corporate unease',
|
||||
palette:
|
||||
'frosted glass, desaturated teal, paper white, warning red pinpoints',
|
||||
posterComposition:
|
||||
'analyst silhouette amid files and clocks, clean tense graphic composition',
|
||||
backdropComposition:
|
||||
'wide operations room with glass walls, layered clock reflections, and negative space',
|
||||
},
|
||||
'Alley Mask': {
|
||||
style: 'neo-noir street photography, bicycle courier realism, coded light marks, wet alleys, handheld camera energy',
|
||||
palette:
|
||||
'wet asphalt, sodium yellow, deep green shadows, chalk-white signal marks',
|
||||
posterComposition:
|
||||
'bike courier and alley geometry, face partly obscured by ordinary rain gear, grounded mystery',
|
||||
backdropComposition:
|
||||
'wide narrow alley after rain with coded signal glyphs and long perspective',
|
||||
},
|
||||
'Neon Sparrow': {
|
||||
style: 'stylized cyberpunk graphic novel art, rooftop rain, neon clue trails, birdlike light motifs, bold ink shapes',
|
||||
palette:
|
||||
'magenta neon, cyan rain, black ink, pale green glow',
|
||||
posterComposition:
|
||||
'rooftop messenger and sparrow-like light trail in bold graphic silhouette',
|
||||
backdropComposition:
|
||||
'wide rain-soaked rooftop cityscape with glowing clues and open sky',
|
||||
},
|
||||
'Cape Makers': {
|
||||
style: 'behind-the-scenes craft docuseries photography, sewing machines, engineering benches, stage fabric, candid hands-at-work details',
|
||||
palette:
|
||||
'indigo cloth, warm workshop bulbs, steel tools, chalk marks',
|
||||
posterComposition:
|
||||
'tailors and engineers at work around stage gear, tactile documentary composition',
|
||||
backdropComposition:
|
||||
'wide busy design studio with fabric rolls, benches, sketches without readable text',
|
||||
},
|
||||
'Citizens of the Beacon': {
|
||||
style: 'quiet social documentary photography, ordinary residents, practical signal lamps, neighborhood streets, compassionate realism',
|
||||
palette:
|
||||
'lamp amber, dusk blue, concrete gray, muted household colors',
|
||||
posterComposition:
|
||||
'ordinary residents around a beacon lamp, intimate human-interest portrait',
|
||||
backdropComposition:
|
||||
'wide neighborhood rescue network at dusk with lamps in windows and street corners',
|
||||
},
|
||||
'Bubble Street Workshop': {
|
||||
style: 'flat kids craft cartoon, simple circles and paper shapes, playful handmade textures, bright but not busy',
|
||||
palette:
|
||||
'bubblegum pink, craft paper tan, sky blue, crayon green, sunflower yellow',
|
||||
posterComposition:
|
||||
'round neighbors building a simple invention from paper, string, and circles',
|
||||
backdropComposition:
|
||||
'wide cheerful street workshop with tables, bubbles, paper circles, and open simple storefront shapes',
|
||||
},
|
||||
'Flatland Detectives': {
|
||||
style: 'flat geometric mystery comedy, shape-language characters, simple shadows, crisp poster design, no realism',
|
||||
palette:
|
||||
'warm gray, detective orange, navy, cream, clue green',
|
||||
posterComposition:
|
||||
'two geometric detectives inspecting a simple shape clue with playful noir lighting',
|
||||
backdropComposition:
|
||||
'wide flat neighborhood map scene with shadows, arrows, and geometric clues',
|
||||
},
|
||||
'Color Orchard': {
|
||||
style: 'preschool nature cartoon, very simple flat rounded shapes, soft friendly faces implied by objects, low detail',
|
||||
palette:
|
||||
'apple red, leaf green, pear yellow, sky blue, soft soil brown',
|
||||
posterComposition:
|
||||
'tiny caretakers under one large color-changing tree, simple fruit shapes and musical notes without text',
|
||||
backdropComposition:
|
||||
'wide gentle orchard made of rounded flat trees changing colors across the morning',
|
||||
},
|
||||
'Museum of Little Robots': {
|
||||
style: 'flat retro-future cartoon, simple rounded robots, clean museum shapes, limited details, soft mid-century geometry',
|
||||
palette:
|
||||
'dusty teal, warm cream, tomato red, brushed metal gray, muted orange',
|
||||
posterComposition:
|
||||
'small helpful robots arranging one oversized exhibit in a quiet museum hall',
|
||||
backdropComposition:
|
||||
'wide retro-future museum after closing with tiny robots, plinths, and simple geometric exhibits',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
profiles[title] ?? {
|
||||
backdropComposition:
|
||||
'immersive genre scene with clean depth and readable negative space',
|
||||
palette: 'balanced multi-color palette with tactile texture',
|
||||
posterComposition:
|
||||
'distinctive key image built around an original fictional subject',
|
||||
style: 'original genre artwork with a clearly differentiated visual language',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function visualDirectionForTitle(title: string): string {
|
||||
const directions: Record<string, string> = {
|
||||
'Crimson Skylark':
|
||||
'an aerial rescue courier navigating storm routes above Meridian City, a solar signal crest glowing in the cloudbreak',
|
||||
'The Voltage Guard':
|
||||
'transit workers protecting an elevated rail grid from a living thunderstorm, blue-white electricity reflected in wet streets',
|
||||
'Midnight Mantle':
|
||||
'an old theatre district at night, a mysterious cloak rumor visualized through shadows, marquee light, and rain-soaked cobblestones',
|
||||
'Quantum Comet':
|
||||
'an orbital cartographer crossing fractured comet trails beside a time-bent armada, star maps glowing in cold blue light',
|
||||
'Orbit of Atlas':
|
||||
'a rescue pilot navigating a cracked moon orbit over a fragile colony, gravity ribbons bending dust and sunlight',
|
||||
'Nova Relay':
|
||||
'twin signal runners racing across a luminous star bridge, urgent light messages streaking toward a blue planet',
|
||||
'Beacon Boy and the Clockwork Garden':
|
||||
'a young inventor and grandmother discovering mechanical garden guardians under a city park, brass leaves and gentle lantern light',
|
||||
'The Invisible Saturday':
|
||||
'two adventurous siblings at a neighborhood fair where only their footprints, floating ribbons, and playful shadows reveal them',
|
||||
'Captain Kindling':
|
||||
'a forest camp at dusk with ember-bright protective magic, young misfit campers gathered around a warm heroic spark',
|
||||
'Masks of Tomorrow':
|
||||
'costume makers, signal engineers, and citizens preparing original hero emblems in a workshop filled with fabric, tools, and soft window light',
|
||||
'Signal Tower Seven':
|
||||
'retro radio operators coordinating rescues from a tall signal room, analog dials, maps, and amber emergency light',
|
||||
'The League of Lanterns':
|
||||
'volunteers carrying lanterns through an impossible blackout, safe glowing paths forming across city streets',
|
||||
'Paper Moon Parade':
|
||||
'a flat paper-cut moon rolling through a tiny town with simple parade shapes and shy children joining in',
|
||||
'Dot and the Cloud Train':
|
||||
'a small dot boarding a soft cloud train that stops at simple weather-color stations',
|
||||
'Tiny Planet Picnic':
|
||||
'children preparing a picnic on a pocket-sized planet where flowers orbit like friendly satellites',
|
||||
'The Quiet Aquarium':
|
||||
'abstract glowing aquarium tanks after closing time, drifting shapes, quiet visitors, and calm water-light',
|
||||
'Square City Sonata':
|
||||
'geometric blocks, windows, crosswalks, and streetlights arranged as a flat city symphony',
|
||||
'Lanterns Under Snow':
|
||||
'neighbors carrying warm paper lanterns across a silent snowy hill town',
|
||||
'Skyline Sentinels':
|
||||
'a young rooftop rescue team watching rails, bridges, and river lights from high above Meridian City at sunrise',
|
||||
'The Aegis Club':
|
||||
'retired heroes in a restored neighborhood social club, worn memorabilia, warm lamps, and a new generation training nearby',
|
||||
'Starfall Academy':
|
||||
'cadets with unstable cosmic powers aboard a school ship near the asteroid belt, classroom windows filled with stars',
|
||||
'Tomorrow Bureau':
|
||||
'future analysts in a glass-walled operations room sorting one-day-ahead disaster files, clocks and reflections layered across the scene',
|
||||
'Alley Mask':
|
||||
'a bike courier stepping through deep city shadows while following strange signal glyphs in narrow alleys after rain',
|
||||
'Neon Sparrow':
|
||||
'a rooftop messenger following glowing clues that appear in midnight rain, neon reflections and small bright wing motifs',
|
||||
'Cape Makers':
|
||||
'tailors, engineers, and artists building original stage gear in a busy design studio, fabric tests and hand-built emblems everywhere',
|
||||
'Citizens of the Beacon':
|
||||
'ordinary residents maintaining a citywide rescue network after the first beacon, hopeful faces lit by practical signal lamps',
|
||||
'Bubble Street Workshop':
|
||||
'neighbors building simple inventions from circles, paper, string, and bright workshop tables',
|
||||
'Flatland Detectives':
|
||||
'two geometric detectives solving gentle neighborhood mysteries using maps, shadows, and shape clues',
|
||||
'Color Orchard':
|
||||
'a preschool orchard changing color each morning while tiny caretakers learn seasons and sound',
|
||||
'Museum of Little Robots':
|
||||
'small helpful robots arranging exhibits after closing time in a quiet retro-future museum',
|
||||
};
|
||||
|
||||
return directions[title] ?? 'an original cinematic genre scene for a fictional streaming title';
|
||||
}
|
||||
|
||||
async function generateMissingArtwork(): Promise<void> {
|
||||
const apiKey = process.env['OPENAI_API_KEY'];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY is required for --generate.');
|
||||
}
|
||||
|
||||
for (const [index, job] of jobs.entries()) {
|
||||
if (existsSync(job.outputPath) && !force) {
|
||||
console.log(
|
||||
`[${index + 1}/${jobs.length}] exists, skipping ${job.filename}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${index + 1}/${jobs.length}] generating ${job.filename} (${job.size}, ${job.quality})`
|
||||
);
|
||||
const imageBytes = await requestImage(apiKey, job);
|
||||
writeFileSync(job.outputPath, imageBytes);
|
||||
|
||||
if (delayMs > 0 && index < jobs.length - 1) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requestImage(apiKey: string, job: ArtworkJob): Promise<Buffer> {
|
||||
const payload = {
|
||||
model,
|
||||
prompt: job.prompt,
|
||||
quality: job.quality,
|
||||
size: job.size,
|
||||
output_format: outputFormat,
|
||||
moderation: 'auto',
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => undefined);
|
||||
|
||||
if (response.ok) {
|
||||
const imageBase64 = readImageBase64(body);
|
||||
return Buffer.from(imageBase64, 'base64');
|
||||
}
|
||||
|
||||
const message = readErrorMessage(body) ?? `${response.status} ${response.statusText}`;
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < 4) {
|
||||
const retryAfter = Number(response.headers.get('retry-after'));
|
||||
const waitMs = Number.isFinite(retryAfter)
|
||||
? retryAfter * 1000
|
||||
: attempt * 15_000;
|
||||
console.warn(
|
||||
`OpenAI image request failed for ${job.filename}: ${message}. Retrying in ${Math.round(
|
||||
waitMs / 1000
|
||||
)}s.`
|
||||
);
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`OpenAI image request failed for ${job.filename}: ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`OpenAI image request failed for ${job.filename}.`);
|
||||
}
|
||||
|
||||
function readImageBase64(body: unknown): string {
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'data' in body &&
|
||||
Array.isArray((body as { data: unknown }).data)
|
||||
) {
|
||||
const first = (body as { data: Array<{ b64_json?: unknown }> }).data[0];
|
||||
if (typeof first?.b64_json === 'string' && first.b64_json.length > 0) {
|
||||
return first.b64_json;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('OpenAI response did not include data[0].b64_json.');
|
||||
}
|
||||
|
||||
function readErrorMessage(body: unknown): string | undefined {
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'error' in body &&
|
||||
typeof (body as { error?: { message?: unknown } }).error?.message === 'string'
|
||||
) {
|
||||
return (body as { error: { message: string } }).error.message;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function validateArtwork(selectedJobs: ArtworkJob[]): Promise<void> {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ArtworkManifest;
|
||||
const expectedManifestCount = selectedJobs.length;
|
||||
|
||||
if (manifest.version !== 1 || manifest.assets.length < expectedManifestCount) {
|
||||
throw new Error(
|
||||
`Manifest has ${manifest.assets.length} assets, expected at least ${expectedManifestCount}.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const job of selectedJobs) {
|
||||
if (!existsSync(job.outputPath)) {
|
||||
throw new Error(`Missing generated artwork: ${relativePath(job.outputPath)}`);
|
||||
}
|
||||
|
||||
const metadata = await sharp(job.outputPath).metadata();
|
||||
const [expectedWidth, expectedHeight] = job.size.split('x').map(Number);
|
||||
|
||||
if (metadata.format !== 'png') {
|
||||
throw new Error(`Artwork is not PNG: ${relativePath(job.outputPath)}`);
|
||||
}
|
||||
|
||||
if (metadata.width !== expectedWidth || metadata.height !== expectedHeight) {
|
||||
throw new Error(
|
||||
`Artwork has invalid dimensions: ${relativePath(job.outputPath)} ` +
|
||||
`expected ${job.size}, got ${metadata.width}x${metadata.height}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Validated ${selectedJobs.length} artwork assets and manifest: ${relativePath(
|
||||
manifestPath
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
function writeManifest(selectedModel: string, selectedJobs: ArtworkJob[]): void {
|
||||
const manifest: ArtworkManifest = {
|
||||
assetRoot: relativeAssetRoot,
|
||||
assets: selectedJobs.map((job) => ({
|
||||
contentType: job.contentType,
|
||||
filename: job.filename,
|
||||
kind: job.kind,
|
||||
manualApprovalStatus: 'pending-human-review',
|
||||
model: selectedModel,
|
||||
prompt: job.prompt,
|
||||
quality: job.quality,
|
||||
size: job.size,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
})),
|
||||
model: selectedModel,
|
||||
outputFormat,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function printDryRun(): void {
|
||||
const manifest = {
|
||||
assetRoot: relativeAssetRoot,
|
||||
assets: jobs.map((job) => ({
|
||||
filename: job.filename,
|
||||
kind: job.kind,
|
||||
model,
|
||||
quality: job.quality,
|
||||
size: job.size,
|
||||
title: job.title,
|
||||
})),
|
||||
count: jobs.length,
|
||||
outputFormat,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
function ensureOutputDirs(): void {
|
||||
mkdirSync(path.join(assetRoot, 'poster'), { recursive: true });
|
||||
mkdirSync(path.join(assetRoot, 'backdrop'), { recursive: true });
|
||||
}
|
||||
|
||||
function getMode(argv: string[]): RunMode {
|
||||
const selectedModes = [
|
||||
argv.includes('--dry-run') ? 'dry-run' : undefined,
|
||||
argv.includes('--generate') ? 'generate' : undefined,
|
||||
argv.includes('--manifest') ? 'manifest' : undefined,
|
||||
argv.includes('--validate') ? 'validate' : undefined,
|
||||
].filter(Boolean) as RunMode[];
|
||||
|
||||
if (selectedModes.length > 1) {
|
||||
throw new Error('Choose only one mode: --dry-run, --manifest, --generate, or --validate.');
|
||||
}
|
||||
|
||||
return selectedModes[0] ?? 'dry-run';
|
||||
}
|
||||
|
||||
function getStringArg(name: string): string | undefined {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function getNumberArg(name: string): number | undefined {
|
||||
const raw = getStringArg(name);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${name} must be a non-negative number.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getQualityArg(name: string): ArtworkQuality | undefined {
|
||||
return getQuality(getStringArg(name));
|
||||
}
|
||||
|
||||
function getQuality(raw: string | undefined): ArtworkQuality | undefined {
|
||||
if (
|
||||
raw === 'low' ||
|
||||
raw === 'medium' ||
|
||||
raw === 'high' ||
|
||||
raw === 'auto'
|
||||
) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error('Quality must be one of: low, medium, high, auto.');
|
||||
}
|
||||
|
||||
function relativePath(filePath: string): string {
|
||||
return path.relative(workspaceRoot, filePath);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
|
||||
const [platformArg, arch = ''] = normalizedArgs;
|
||||
const currentPlatform =
|
||||
platformArg ??
|
||||
(process.platform === 'darwin'
|
||||
? 'macos'
|
||||
: process.platform === 'win32'
|
||||
? 'windows'
|
||||
: 'linux');
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const executablesRoot = path.join(workspaceRoot, 'dist', 'executables');
|
||||
const remoteDebuggingPort = '9222';
|
||||
|
||||
function ensureFile(filePath) {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
}
|
||||
|
||||
function findUnpackedExecutable(prefix, executableNames) {
|
||||
if (!fs.existsSync(executablesRoot)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unpackedDirs = fs
|
||||
.readdirSync(executablesRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith(prefix) &&
|
||||
entry.name.endsWith('-unpacked')
|
||||
)
|
||||
.map((entry) => path.join(executablesRoot, entry.name));
|
||||
|
||||
for (const unpackedDir of unpackedDirs) {
|
||||
for (const executableName of executableNames) {
|
||||
const executablePath = path.join(unpackedDir, executableName);
|
||||
if (ensureFile(executablePath)) {
|
||||
return executablePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePackagedExecutable() {
|
||||
if (currentPlatform === 'macos') {
|
||||
const candidates = [
|
||||
{
|
||||
arch: 'x64',
|
||||
executable: path.join(
|
||||
executablesRoot,
|
||||
'mac',
|
||||
'IPTVnator.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'IPTVnator'
|
||||
),
|
||||
},
|
||||
{
|
||||
arch: 'arm64',
|
||||
executable: path.join(
|
||||
executablesRoot,
|
||||
'mac-arm64',
|
||||
'IPTVnator.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'IPTVnator'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const match = candidates.find(
|
||||
(candidate) => (!arch || candidate.arch === arch) && ensureFile(candidate.executable)
|
||||
);
|
||||
return match?.executable;
|
||||
}
|
||||
|
||||
if (currentPlatform === 'windows') {
|
||||
return findUnpackedExecutable('win', ['IPTVnator.exe']);
|
||||
}
|
||||
|
||||
if (currentPlatform === 'linux') {
|
||||
return findUnpackedExecutable('linux', ['IPTVnator', 'iptvnator']);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const executable = resolvePackagedExecutable();
|
||||
|
||||
if (!executable) {
|
||||
console.error(
|
||||
`Unable to resolve a packaged executable for ${currentPlatform}${arch ? ` (${arch})` : ''}.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Launching packaged app: ${executable}`);
|
||||
console.log(
|
||||
`After the window opens, connect with: agent-browser --cdp ${remoteDebuggingPort} tab list`
|
||||
);
|
||||
|
||||
const child = spawn(executable, [`--remote-debugging-port=${remoteDebuggingPort}`], {
|
||||
stdio: 'inherit',
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
|
||||
const [targetDir, ...jestArgs] = process.argv.slice(2);
|
||||
|
||||
if (!targetDir) {
|
||||
console.error(
|
||||
'Usage: node tools/testing/run-web-esm-lib-tests.mjs <directory> [jest args...]'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const absoluteTargetDir = path.resolve(workspaceRoot, targetDir);
|
||||
|
||||
function collectSpecFiles(directory) {
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
const specFiles = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
specFiles.push(...collectSpecFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith('.spec.ts') || entry.name.endsWith('.test.ts'))
|
||||
) {
|
||||
specFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return specFiles;
|
||||
}
|
||||
|
||||
const specFiles = collectSpecFiles(absoluteTargetDir).sort();
|
||||
|
||||
if (specFiles.length === 0) {
|
||||
console.error(`No spec files found under ${targetDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jestBin = path.resolve(workspaceRoot, 'node_modules/jest/bin/jest.js');
|
||||
const jestConfig = path.resolve(
|
||||
workspaceRoot,
|
||||
'jest.web-esm.workspace.ts'
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[jestBin, '--config', jestConfig, '--runTestsByPath', ...specFiles, ...jestArgs],
|
||||
{
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
Reference in New Issue
Block a user