diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index 0b29d9101..7228c2644 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -14,21 +14,30 @@ pnpm nx run xtream-mock-server:serve # Start with file-watch (auto-restart on code changes) pnpm nx run xtream-mock-server:serve-with-watch + +# Start the mock server plus the Electron app +pnpm run serve:marketing-demo + +# Start the mock server plus the browser web app +pnpm run serve:marketing-demo:web ``` --- ## Available Scenarios (credential pairs) -| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status | -|----------|----------|----------|-----------|----------|-------------|-----------|--------| -| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active | -| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | -| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | -| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | -| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | -| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | -| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | +| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status | +| ----------- | ----------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- | +| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active | +| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | +| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active | +| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | +| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | +| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | +| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | +| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | +| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | +| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account). @@ -40,27 +49,29 @@ Any other credential pair is auto-generated using a hash of `username:password` `GET /player_api.php?action=&username=&password=

[&...]` -| Action | Description | -|--------|-------------| -| (none) / `get_account_info` | User info + server info | -| `get_live_categories` | Live TV categories | -| `get_vod_categories` | VOD (movie) categories | -| `get_series_categories` | Series categories | -| `get_live_streams` | Live streams (optionally filtered by `category_id`) | -| `get_vod_streams` | VOD streams (optionally filtered by `category_id`) | -| `get_series` | Series list (optionally filtered by `category_id`) | -| `get_vod_info?vod_id=` | Full movie details | -| `get_series_info?series_id=` | Full series info (seasons + episodes) | -| `get_short_epg?stream_id=[&limit=N]` | EPG listings for a live channel | -| `get_simple_data_table?stream_id=` | Full per-channel EPG schedule | -| `get_simple_date_table?stream_id=` | Legacy typo alias for full per-channel EPG schedule | +| Action | Description | +| ---------------------------------------- | --------------------------------------------------- | +| (none) / `get_account_info` | User info + server info | +| `get_live_categories` | Live TV categories | +| `get_vod_categories` | VOD (movie) categories | +| `get_series_categories` | Series categories | +| `get_live_streams` | Live streams (optionally filtered by `category_id`) | +| `get_vod_streams` | VOD streams (optionally filtered by `category_id`) | +| `get_series` | Series list (optionally filtered by `category_id`) | +| `get_vod_info?vod_id=` | Full movie details | +| `get_series_info?series_id=` | Full series info (seasons + episodes) | +| `get_short_epg?stream_id=[&limit=N]` | EPG listings for a live channel | +| `get_simple_data_table?stream_id=` | Full per-channel EPG schedule | +| `get_simple_date_table?stream_id=` | Legacy typo alias for full per-channel EPG schedule | ### PWA CORS Proxy Endpoint IPTVnator's PWA routes Xtream calls through a backend proxy: + ``` GET /xtream?url=&action=&username=&password=

``` + Response: `{ payload: , action: }` ### Stream URLs (stub redirects) @@ -133,12 +144,39 @@ application code. - **Cached per session**: Data generated once on first request, reused until `/reset` - **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API) - **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests +- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing` - **Timestamp precedence coverage**: The `epg:epg` scenario intentionally shifts raw `start` / `end` strings away from `start_timestamp` / `stop_timestamp` so UI tests can prove timestamps drive rendering - **Stream IDs**: Live 10,000+, VOD 20,000+, Series 30,000+ - **Category IDs**: Live 101+, VOD 201+, Series 301+ --- +## Release Demo Artwork + +The `marketing:marketing` fixture uses 30 original fictional titles for release +screenshots. Poster and backdrop PNGs are served from: + +```text +apps/xtream-mock-server/public/marketing/{poster,backdrop}/ +``` + +Generate or validate those assets with: + +```bash +pnpm release:artwork:dry-run +pnpm release:artwork:generate +pnpm release:artwork:validate +``` + +`release:artwork:generate` uses `gpt-image-2` through the OpenAI Image API and +requires `OPENAI_API_KEY`. The screenshot capture workflow only reads committed +local assets; it does not call OpenAI. If a local PNG is missing, the mock +server falls back to its deterministic SVG renderer for development continuity. +The prompt manifest deliberately varies genres and visual media across titles +so the catalog does not collapse into one superhero/poster style. + +--- + ## Architecture See `docs/architecture/xtream-mock-server.md` for a full description of the diff --git a/apps/xtream-mock-server/public/marketing/backdrop/beacon-boy-and-the-clockwork-garden.png b/apps/xtream-mock-server/public/marketing/backdrop/beacon-boy-and-the-clockwork-garden.png new file mode 100644 index 000000000..03ca7c8df Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/beacon-boy-and-the-clockwork-garden.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/captain-kindling.png b/apps/xtream-mock-server/public/marketing/backdrop/captain-kindling.png new file mode 100644 index 000000000..5bfac3133 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/captain-kindling.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/crimson-skylark.png b/apps/xtream-mock-server/public/marketing/backdrop/crimson-skylark.png new file mode 100644 index 000000000..1f2b9f66d Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/crimson-skylark.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/dot-and-the-cloud-train.png b/apps/xtream-mock-server/public/marketing/backdrop/dot-and-the-cloud-train.png new file mode 100644 index 000000000..a0a43a031 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/dot-and-the-cloud-train.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/masks-of-tomorrow.png b/apps/xtream-mock-server/public/marketing/backdrop/masks-of-tomorrow.png new file mode 100644 index 000000000..d58bfb5fa Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/masks-of-tomorrow.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/midnight-mantle.png b/apps/xtream-mock-server/public/marketing/backdrop/midnight-mantle.png new file mode 100644 index 000000000..fc1dfe139 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/midnight-mantle.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/nova-relay.png b/apps/xtream-mock-server/public/marketing/backdrop/nova-relay.png new file mode 100644 index 000000000..beb5775c3 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/nova-relay.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/orbit-of-atlas.png b/apps/xtream-mock-server/public/marketing/backdrop/orbit-of-atlas.png new file mode 100644 index 000000000..11433979f Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/orbit-of-atlas.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/paper-moon-parade.png b/apps/xtream-mock-server/public/marketing/backdrop/paper-moon-parade.png new file mode 100644 index 000000000..de5230c10 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/paper-moon-parade.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/quantum-comet.png b/apps/xtream-mock-server/public/marketing/backdrop/quantum-comet.png new file mode 100644 index 000000000..e8177479a Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/quantum-comet.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/signal-tower-seven.png b/apps/xtream-mock-server/public/marketing/backdrop/signal-tower-seven.png new file mode 100644 index 000000000..a882329f9 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/signal-tower-seven.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/the-invisible-saturday.png b/apps/xtream-mock-server/public/marketing/backdrop/the-invisible-saturday.png new file mode 100644 index 000000000..65ab9d51e Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/the-invisible-saturday.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/the-voltage-guard.png b/apps/xtream-mock-server/public/marketing/backdrop/the-voltage-guard.png new file mode 100644 index 000000000..76f65bbc1 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/the-voltage-guard.png differ diff --git a/apps/xtream-mock-server/public/marketing/backdrop/tiny-planet-picnic.png b/apps/xtream-mock-server/public/marketing/backdrop/tiny-planet-picnic.png new file mode 100644 index 000000000..389d6dcca Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/backdrop/tiny-planet-picnic.png differ diff --git a/apps/xtream-mock-server/public/marketing/manifest.json b/apps/xtream-mock-server/public/marketing/manifest.json new file mode 100644 index 000000000..d1c426402 --- /dev/null +++ b/apps/xtream-mock-server/public/marketing/manifest.json @@ -0,0 +1,728 @@ +{ + "assetRoot": "apps/xtream-mock-server/public/marketing", + "assets": [ + { + "contentType": "movie", + "filename": "poster/crimson-skylark.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Crimson Skylark\" (2026).\nGenre: Aerial Rescue Adventure.\nStory seed: Every sunrise needs a signal. A high-altitude rescue courier follows sunrise signals through the storm routes above Meridian City.\nVisual direction: an aerial rescue courier navigating storm routes above Meridian City, a solar signal crest glowing in the cloudbreak\nVisual medium and style: golden-hour aerial adventure photography with realistic rescue-drama production design, wind, clouds, practical flight gear, and natural lens flare\nPalette and texture: sunrise coral, storm gray, aviation green, grainy but realistic atmospheric haze\nSafety 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.\nComposition: portrait 2:3 single rescue courier against a huge storm sky, diagonal flight path, naturalistic poster photography; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "crimson-skylark", + "title": "Crimson Skylark" + }, + { + "contentType": "movie", + "filename": "backdrop/crimson-skylark.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Crimson Skylark\" (2026).\nGenre: Aerial Rescue Adventure.\nStory seed: Every sunrise needs a signal. A high-altitude rescue courier follows sunrise signals through the storm routes above Meridian City.\nVisual direction: an aerial rescue courier navigating storm routes above Meridian City, a solar signal crest glowing in the cloudbreak\nVisual medium and style: golden-hour aerial adventure photography with realistic rescue-drama production design, wind, clouds, practical flight gear, and natural lens flare\nPalette and texture: sunrise coral, storm gray, aviation green, grainy but realistic atmospheric haze\nSafety 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.\nComposition: wide 16:9 vast sky route above a modern city, layered storm clouds, small courier silhouette, breathable horizon; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "crimson-skylark", + "title": "Crimson Skylark" + }, + { + "contentType": "movie", + "filename": "poster/the-voltage-guard.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The Voltage Guard\" (2025).\nGenre: Disaster Action Thriller.\nStory seed: Power is only heroic when it is shared. Four transit workers race to keep the night trains moving as a thunderstorm turns the city grid unstable.\nVisual direction: transit workers protecting an elevated rail grid from a living thunderstorm, blue-white electricity reflected in wet streets\nVisual medium and style: gritty disaster-thriller photography, rain-soaked infrastructure, documentary realism, sparks, emergency work lights, no fantasy costumes\nPalette and texture: steel blue, sodium orange, wet asphalt black, white electrical flares\nSafety 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.\nComposition: portrait 2:3 workers and rail geometry forming a tense action triangle, practical uniforms and tools; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "the-voltage-guard", + "title": "The Voltage Guard" + }, + { + "contentType": "movie", + "filename": "backdrop/the-voltage-guard.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The Voltage Guard\" (2025).\nGenre: Disaster Action Thriller.\nStory seed: Power is only heroic when it is shared. Four transit workers race to keep the night trains moving as a thunderstorm turns the city grid unstable.\nVisual direction: transit workers protecting an elevated rail grid from a living thunderstorm, blue-white electricity reflected in wet streets\nVisual medium and style: gritty disaster-thriller photography, rain-soaked infrastructure, documentary realism, sparks, emergency work lights, no fantasy costumes\nPalette and texture: steel blue, sodium orange, wet asphalt black, white electrical flares\nSafety 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.\nComposition: wide 16:9 wide elevated transit grid under unstable weather, workers small in frame, strong industrial depth; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "the-voltage-guard", + "title": "The Voltage Guard" + }, + { + "contentType": "movie", + "filename": "poster/midnight-mantle.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Midnight Mantle\" (2024).\nGenre: Gothic Mystery.\nStory seed: The city has one last trick. A retired stage magician investigates a living cloak rumor haunting the old theatre district after dark.\nVisual direction: an old theatre district at night, a mysterious cloak rumor visualized through shadows, marquee light, and rain-soaked cobblestones\nVisual medium and style: gothic noir illustration with theatrical shadow shapes, old print texture, rain, velvet blacks, and painterly mystery atmosphere\nPalette and texture: deep violet, amber marquee light, wet black streets, muted crimson accents\nSafety 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.\nComposition: portrait 2:3 theatre silhouette and sweeping cloak as an abstract mystery shape, not a superhero pose; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "midnight-mantle", + "title": "Midnight Mantle" + }, + { + "contentType": "movie", + "filename": "backdrop/midnight-mantle.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Midnight Mantle\" (2024).\nGenre: Gothic Mystery.\nStory seed: The city has one last trick. A retired stage magician investigates a living cloak rumor haunting the old theatre district after dark.\nVisual direction: an old theatre district at night, a mysterious cloak rumor visualized through shadows, marquee light, and rain-soaked cobblestones\nVisual medium and style: gothic noir illustration with theatrical shadow shapes, old print texture, rain, velvet blacks, and painterly mystery atmosphere\nPalette and texture: deep violet, amber marquee light, wet black streets, muted crimson accents\nSafety 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.\nComposition: wide 16:9 empty theatre district at night, marquee glow without readable letters, cloak-like shadow crossing rooftops; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "midnight-mantle", + "title": "Midnight Mantle" + }, + { + "contentType": "movie", + "filename": "poster/quantum-comet.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Quantum Comet\" (2026).\nGenre: Cosmic Science Fiction.\nStory seed: Faster than tomorrow. An orbital cartographer rides a fractured comet trail to stop a time-lost armada.\nVisual direction: an orbital cartographer crossing fractured comet trails beside a time-bent armada, star maps glowing in cold blue light\nVisual medium and style: hard science-fiction concept art with crisp spacecraft detail, star charts, comet ice, scale diagrams, and luminous navigation UI shapes without readable text\nPalette and texture: icy cyan, white comet dust, deep navy, precise magenta navigation glows\nSafety 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.\nComposition: portrait 2:3 cartographer figure and fractured comet arc arranged like a scientific expedition poster; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "quantum-comet", + "title": "Quantum Comet" + }, + { + "contentType": "movie", + "filename": "backdrop/quantum-comet.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Quantum Comet\" (2026).\nGenre: Cosmic Science Fiction.\nStory seed: Faster than tomorrow. An orbital cartographer rides a fractured comet trail to stop a time-lost armada.\nVisual direction: an orbital cartographer crossing fractured comet trails beside a time-bent armada, star maps glowing in cold blue light\nVisual medium and style: hard science-fiction concept art with crisp spacecraft detail, star charts, comet ice, scale diagrams, and luminous navigation UI shapes without readable text\nPalette and texture: icy cyan, white comet dust, deep navy, precise magenta navigation glows\nSafety 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.\nComposition: wide 16:9 wide comet trail and distant armada silhouettes, clean negative space around the center; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "quantum-comet", + "title": "Quantum Comet" + }, + { + "contentType": "movie", + "filename": "poster/orbit-of-atlas.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Orbit of Atlas\" (2025).\nGenre: Cosmic Survival Adventure.\nStory seed: Hold the sky. A rescue pilot navigates a fractured moon orbit long enough for a remote colony to evacuate.\nVisual direction: a rescue pilot navigating a cracked moon orbit over a fragile colony, gravity ribbons bending dust and sunlight\nVisual medium and style: epic cosmic survival painting with mineral surfaces, colony lights, gravitational dust ribbons, and solemn scale\nPalette and texture: burnt gold, moon gray, dusty umber, cold blue space shadows\nSafety 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.\nComposition: portrait 2:3 small rescue pilot dwarfed by a cracked moon, monumental survival mood; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "orbit-of-atlas", + "title": "Orbit of Atlas" + }, + { + "contentType": "movie", + "filename": "backdrop/orbit-of-atlas.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Orbit of Atlas\" (2025).\nGenre: Cosmic Survival Adventure.\nStory seed: Hold the sky. A rescue pilot navigates a fractured moon orbit long enough for a remote colony to evacuate.\nVisual direction: a rescue pilot navigating a cracked moon orbit over a fragile colony, gravity ribbons bending dust and sunlight\nVisual medium and style: epic cosmic survival painting with mineral surfaces, colony lights, gravitational dust ribbons, and solemn scale\nPalette and texture: burnt gold, moon gray, dusty umber, cold blue space shadows\nSafety 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.\nComposition: wide 16:9 fractured moon over a remote colony, dust and sunlight bending in wide arcs; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "orbit-of-atlas", + "title": "Orbit of Atlas" + }, + { + "contentType": "movie", + "filename": "poster/nova-relay.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Nova Relay\" (2023).\nGenre: Space Opera.\nStory seed: The message arrives in light. Twin signal runners cross a star bridge to deliver the last warning before a solar network goes dark.\nVisual direction: twin signal runners racing across a luminous star bridge, urgent light messages streaking toward a blue planet\nVisual medium and style: sleek space-opera animation art with clean silhouettes, radiant motion trails, glossy star-bridge architecture, and optimistic color\nPalette and texture: electric teal, royal blue, white light trails, warm planetary glow\nSafety 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.\nComposition: portrait 2:3 twin runners crossing a luminous bridge with strong graphic diagonals; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "nova-relay", + "title": "Nova Relay" + }, + { + "contentType": "movie", + "filename": "backdrop/nova-relay.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Nova Relay\" (2023).\nGenre: Space Opera.\nStory seed: The message arrives in light. Twin signal runners cross a star bridge to deliver the last warning before a solar network goes dark.\nVisual direction: twin signal runners racing across a luminous star bridge, urgent light messages streaking toward a blue planet\nVisual medium and style: sleek space-opera animation art with clean silhouettes, radiant motion trails, glossy star-bridge architecture, and optimistic color\nPalette and texture: electric teal, royal blue, white light trails, warm planetary glow\nSafety 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.\nComposition: wide 16:9 star bridge leading toward a blue planet, two tiny figures and sweeping light messages; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "nova-relay", + "title": "Nova Relay" + }, + { + "contentType": "movie", + "filename": "poster/beacon-boy-and-the-clockwork-garden.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Beacon Boy and the Clockwork Garden\" (2026).\nGenre: Animated Family Fantasy.\nStory seed: Small heroes grow tall. A young inventor and his grandmother awaken a garden of mechanical guardians beneath the city park.\nVisual direction: a young inventor and grandmother discovering mechanical garden guardians under a city park, brass leaves and gentle lantern light\nVisual medium and style: storybook 3D animated fantasy, handmade brass mechanisms, moss, warm lantern light, rounded forms, and tactile toy-like detail\nPalette and texture: moss green, brass gold, warm amber, soft twilight blue\nSafety 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.\nComposition: portrait 2:3 young inventor, grandmother, and clockwork garden guardian in a warm discovery tableau; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "beacon-boy-and-the-clockwork-garden", + "title": "Beacon Boy and the Clockwork Garden" + }, + { + "contentType": "movie", + "filename": "backdrop/beacon-boy-and-the-clockwork-garden.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Beacon Boy and the Clockwork Garden\" (2026).\nGenre: Animated Family Fantasy.\nStory seed: Small heroes grow tall. A young inventor and his grandmother awaken a garden of mechanical guardians beneath the city park.\nVisual direction: a young inventor and grandmother discovering mechanical garden guardians under a city park, brass leaves and gentle lantern light\nVisual medium and style: storybook 3D animated fantasy, handmade brass mechanisms, moss, warm lantern light, rounded forms, and tactile toy-like detail\nPalette and texture: moss green, brass gold, warm amber, soft twilight blue\nSafety 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.\nComposition: wide 16:9 hidden underground garden with gears, vines, lanterns, and gentle mechanical guardians; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "beacon-boy-and-the-clockwork-garden", + "title": "Beacon Boy and the Clockwork Garden" + }, + { + "contentType": "movie", + "filename": "poster/the-invisible-saturday.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The Invisible Saturday\" (2024).\nGenre: Family, Adventure.\nStory seed: Seen or unseen, courage counts. Two siblings learn to turn invisible every Saturday and use the gift to save their neighborhood fair.\nVisual direction: two adventurous siblings at a neighborhood fair where only their footprints, floating ribbons, and playful shadows reveal them\nVisual medium and style: bright claymation-inspired family comedy, miniature fairground sets, visible handmade textures, playful invisible-person gags\nPalette and texture: cotton-candy pink, sunny yellow, grass green, soft sky blue\nSafety 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.\nComposition: portrait 2:3 floating ribbons, footprints, and balloons implying invisible siblings at a fair; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "the-invisible-saturday", + "title": "The Invisible Saturday" + }, + { + "contentType": "movie", + "filename": "backdrop/the-invisible-saturday.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The Invisible Saturday\" (2024).\nGenre: Family, Adventure.\nStory seed: Seen or unseen, courage counts. Two siblings learn to turn invisible every Saturday and use the gift to save their neighborhood fair.\nVisual direction: two adventurous siblings at a neighborhood fair where only their footprints, floating ribbons, and playful shadows reveal them\nVisual medium and style: bright claymation-inspired family comedy, miniature fairground sets, visible handmade textures, playful invisible-person gags\nPalette and texture: cotton-candy pink, sunny yellow, grass green, soft sky blue\nSafety 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.\nComposition: wide 16:9 wide neighborhood fair scene with playful impossible traces and generous open sky; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "the-invisible-saturday", + "title": "The Invisible Saturday" + }, + { + "contentType": "movie", + "filename": "poster/captain-kindling.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Captain Kindling\" (2022).\nGenre: Animated Family Comedy.\nStory seed: A spark can lead the way. A camp counselor with ember-bright powers teaches a team of misfits how to protect a forest town.\nVisual direction: a forest camp at dusk with ember-bright protective magic, young misfit campers gathered around a warm heroic spark\nVisual medium and style: warm hand-painted camping comedy illustration, cozy forest dusk, ember glow, imperfect brush texture, gentle humor\nPalette and texture: campfire orange, pine green, twilight purple, warm tan canvas\nSafety 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.\nComposition: portrait 2:3 camp counselor spark and misfit camper silhouettes around a glowing clearing; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "captain-kindling", + "title": "Captain Kindling" + }, + { + "contentType": "movie", + "filename": "backdrop/captain-kindling.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Captain Kindling\" (2022).\nGenre: Animated Family Comedy.\nStory seed: A spark can lead the way. A camp counselor with ember-bright powers teaches a team of misfits how to protect a forest town.\nVisual direction: a forest camp at dusk with ember-bright protective magic, young misfit campers gathered around a warm heroic spark\nVisual medium and style: warm hand-painted camping comedy illustration, cozy forest dusk, ember glow, imperfect brush texture, gentle humor\nPalette and texture: campfire orange, pine green, twilight purple, warm tan canvas\nSafety 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.\nComposition: wide 16:9 wide forest camp at dusk with cabin lights, ember trails, and friendly wilderness depth; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "captain-kindling", + "title": "Captain Kindling" + }, + { + "contentType": "movie", + "filename": "poster/paper-moon-parade.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Paper Moon Parade\" (2026).\nGenre: Flat Kids Fantasy.\nStory seed: Fold the night into wonder. A paper-cut moon rolls through a tiny town and invites every shy child to join a midnight parade.\nVisual direction: a flat paper-cut moon rolling through a tiny town with simple parade shapes and shy children joining in\nVisual medium and style: simple flat paper-cut illustration for children, large clean shapes, visible paper grain, gentle handmade collage, no realistic rendering\nPalette and texture: midnight blue, butter yellow, pale lavender, soft coral, warm paper white\nSafety 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.\nComposition: portrait 2:3 big smiling paper moon, tiny town silhouettes, parade shapes, lots of clean negative space; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "paper-moon-parade", + "title": "Paper Moon Parade" + }, + { + "contentType": "movie", + "filename": "backdrop/paper-moon-parade.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Paper Moon Parade\" (2026).\nGenre: Flat Kids Fantasy.\nStory seed: Fold the night into wonder. A paper-cut moon rolls through a tiny town and invites every shy child to join a midnight parade.\nVisual direction: a flat paper-cut moon rolling through a tiny town with simple parade shapes and shy children joining in\nVisual medium and style: simple flat paper-cut illustration for children, large clean shapes, visible paper grain, gentle handmade collage, no realistic rendering\nPalette and texture: midnight blue, butter yellow, pale lavender, soft coral, warm paper white\nSafety 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.\nComposition: wide 16:9 wide paper-cut town street under a rolling moon with simple parade banners and sparse stars; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "paper-moon-parade", + "title": "Paper Moon Parade" + }, + { + "contentType": "movie", + "filename": "poster/dot-and-the-cloud-train.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Dot and the Cloud Train\" (2025).\nGenre: Preschool Cartoon Adventure.\nStory seed: Every stop is a color. A small dot boards a soft cloud train and learns the colors of weather with a group of round little friends.\nVisual direction: a small dot boarding a soft cloud train that stops at simple weather-color stations\nVisual medium and style: ultra-simple preschool cartoon, rounded vector shapes, soft gradients, thick friendly outlines, very low detail\nPalette and texture: sky blue, marshmallow white, lemon yellow, strawberry pink, mint green\nSafety 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.\nComposition: portrait 2:3 small dot character stepping onto a rounded cloud train, clear simple shapes for young kids; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "dot-and-the-cloud-train", + "title": "Dot and the Cloud Train" + }, + { + "contentType": "movie", + "filename": "backdrop/dot-and-the-cloud-train.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Dot and the Cloud Train\" (2025).\nGenre: Preschool Cartoon Adventure.\nStory seed: Every stop is a color. A small dot boards a soft cloud train and learns the colors of weather with a group of round little friends.\nVisual direction: a small dot boarding a soft cloud train that stops at simple weather-color stations\nVisual medium and style: ultra-simple preschool cartoon, rounded vector shapes, soft gradients, thick friendly outlines, very low detail\nPalette and texture: sky blue, marshmallow white, lemon yellow, strawberry pink, mint green\nSafety 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.\nComposition: wide 16:9 wide pastel sky with a cloud train crossing simple weather-color stops; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "dot-and-the-cloud-train", + "title": "Dot and the Cloud Train" + }, + { + "contentType": "movie", + "filename": "poster/tiny-planet-picnic.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Tiny Planet Picnic\" (2024).\nGenre: Simple Family Sci-Fi Cartoon.\nStory seed: Pack a lunch for the stars. Three children prepare a picnic on a pocket-sized planet where flowers orbit like friendly satellites.\nVisual direction: children preparing a picnic on a pocket-sized planet where flowers orbit like friendly satellites\nVisual medium and style: minimal flat family sci-fi cartoon, clean vector-like shapes, playful scale, sparse details, soft geometric planets\nPalette and texture: peach, teal, lilac, sunny yellow, deep space blue used sparingly\nSafety 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.\nComposition: portrait 2:3 three children on a tiny round planet with picnic blanket and orbiting flowers; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "tiny-planet-picnic", + "title": "Tiny Planet Picnic" + }, + { + "contentType": "movie", + "filename": "backdrop/tiny-planet-picnic.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Tiny Planet Picnic\" (2024).\nGenre: Simple Family Sci-Fi Cartoon.\nStory seed: Pack a lunch for the stars. Three children prepare a picnic on a pocket-sized planet where flowers orbit like friendly satellites.\nVisual direction: children preparing a picnic on a pocket-sized planet where flowers orbit like friendly satellites\nVisual medium and style: minimal flat family sci-fi cartoon, clean vector-like shapes, playful scale, sparse details, soft geometric planets\nPalette and texture: peach, teal, lilac, sunny yellow, deep space blue used sparingly\nSafety 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.\nComposition: wide 16:9 wide tiny planet landscape with flowers orbiting like satellites and open simple sky; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "tiny-planet-picnic", + "title": "Tiny Planet Picnic" + }, + { + "contentType": "movie", + "filename": "poster/masks-of-tomorrow.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Masks of Tomorrow\" (2025).\nGenre: Creative Documentary.\nStory seed: Behind every emblem is a city. A fictional documentary profile of costume makers, signal engineers, and citizens building a city festival from scratch.\nVisual direction: costume makers, signal engineers, and citizens preparing original hero emblems in a workshop filled with fabric, tools, and soft window light\nVisual medium and style: fashion-documentary editorial photography, fabric tables, workshop tools, mirrors, fittings, and soft daylight realism\nPalette and texture: cream fabric, graphite tools, tomato red thread, daylight blue shadows\nSafety 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.\nComposition: portrait 2:3 hands, mannequins, and unfinished masks arranged as an editorial magazine cover image; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "masks-of-tomorrow", + "title": "Masks of Tomorrow" + }, + { + "contentType": "movie", + "filename": "backdrop/masks-of-tomorrow.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Masks of Tomorrow\" (2025).\nGenre: Creative Documentary.\nStory seed: Behind every emblem is a city. A fictional documentary profile of costume makers, signal engineers, and citizens building a city festival from scratch.\nVisual direction: costume makers, signal engineers, and citizens preparing original hero emblems in a workshop filled with fabric, tools, and soft window light\nVisual medium and style: fashion-documentary editorial photography, fabric tables, workshop tools, mirrors, fittings, and soft daylight realism\nPalette and texture: cream fabric, graphite tools, tomato red thread, daylight blue shadows\nSafety 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.\nComposition: wide 16:9 busy workshop with textiles and signal equipment, human craft details, no logo marks; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "masks-of-tomorrow", + "title": "Masks of Tomorrow" + }, + { + "contentType": "movie", + "filename": "poster/signal-tower-seven.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Signal Tower Seven\" (2023).\nGenre: Retro Rescue Drama.\nStory seed: Not all heroes fly. A retro-styled chronicle of radio operators who coordinate mountain rescues during a record winter blackout.\nVisual direction: retro radio operators coordinating rescues from a tall signal room, analog dials, maps, and amber emergency light\nVisual medium and style: retro 1970s disaster-drama poster art, analog radio equipment, snowy mountain rescue maps, screen-print texture\nPalette and texture: faded mustard, radio-room brown, emergency red, winter blue\nSafety 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.\nComposition: portrait 2:3 radio operator profile, signal tower, and storm map shapes in vintage rescue-drama layout; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "signal-tower-seven", + "title": "Signal Tower Seven" + }, + { + "contentType": "movie", + "filename": "backdrop/signal-tower-seven.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Signal Tower Seven\" (2023).\nGenre: Retro Rescue Drama.\nStory seed: Not all heroes fly. A retro-styled chronicle of radio operators who coordinate mountain rescues during a record winter blackout.\nVisual direction: retro radio operators coordinating rescues from a tall signal room, analog dials, maps, and amber emergency light\nVisual medium and style: retro 1970s disaster-drama poster art, analog radio equipment, snowy mountain rescue maps, screen-print texture\nPalette and texture: faded mustard, radio-room brown, emergency red, winter blue\nSafety 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.\nComposition: wide 16:9 wide analog signal room overlooking winter mountains and storm lights; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "signal-tower-seven", + "title": "Signal Tower Seven" + }, + { + "contentType": "movie", + "filename": "poster/the-league-of-lanterns.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The League of Lanterns\" (2024).\nGenre: Human Interest Documentary.\nStory seed: When the grid fails, people glow. A narrated archive of a volunteer lantern network that lights safe paths during impossible blackouts.\nVisual direction: volunteers carrying lanterns through an impossible blackout, safe glowing paths forming across city streets\nVisual medium and style: human-interest documentary photography with natural faces, handheld lanterns, dark streets, and hopeful realism\nPalette and texture: blackout navy, lantern gold, brick red, soft skin tones\nSafety 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.\nComposition: portrait 2:3 volunteers carrying lanterns through darkness, intimate eye-level composition; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "the-league-of-lanterns", + "title": "The League of Lanterns" + }, + { + "contentType": "movie", + "filename": "backdrop/the-league-of-lanterns.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The League of Lanterns\" (2024).\nGenre: Human Interest Documentary.\nStory seed: When the grid fails, people glow. A narrated archive of a volunteer lantern network that lights safe paths during impossible blackouts.\nVisual direction: volunteers carrying lanterns through an impossible blackout, safe glowing paths forming across city streets\nVisual medium and style: human-interest documentary photography with natural faces, handheld lanterns, dark streets, and hopeful realism\nPalette and texture: blackout navy, lantern gold, brick red, soft skin tones\nSafety 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.\nComposition: wide 16:9 wide city block during blackout with lantern paths and community silhouettes; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "the-league-of-lanterns", + "title": "The League of Lanterns" + }, + { + "contentType": "movie", + "filename": "poster/the-quiet-aquarium.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The Quiet Aquarium\" (2026).\nGenre: Abstract Meditative Drama.\nStory seed: Silence has currents. An abstract night watch follows drifting shapes, quiet visitors, and glowing tanks inside a closed city aquarium.\nVisual direction: abstract glowing aquarium tanks after closing time, drifting shapes, quiet visitors, and calm water-light\nVisual medium and style: abstract meditative poster art, flat translucent shapes, slow gradients, simplified fish silhouettes, calm museum-like composition\nPalette and texture: deep sea blue, glass cyan, muted violet, soft black, small coral accents\nSafety 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.\nComposition: portrait 2:3 large abstract aquarium window with drifting shapes and one quiet visitor silhouette; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "the-quiet-aquarium", + "title": "The Quiet Aquarium" + }, + { + "contentType": "movie", + "filename": "backdrop/the-quiet-aquarium.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"The Quiet Aquarium\" (2026).\nGenre: Abstract Meditative Drama.\nStory seed: Silence has currents. An abstract night watch follows drifting shapes, quiet visitors, and glowing tanks inside a closed city aquarium.\nVisual direction: abstract glowing aquarium tanks after closing time, drifting shapes, quiet visitors, and calm water-light\nVisual medium and style: abstract meditative poster art, flat translucent shapes, slow gradients, simplified fish silhouettes, calm museum-like composition\nPalette and texture: deep sea blue, glass cyan, muted violet, soft black, small coral accents\nSafety 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.\nComposition: wide 16:9 wide closed aquarium interior with glowing tanks, drifting abstract fish shapes, and quiet negative space; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "the-quiet-aquarium", + "title": "The Quiet Aquarium" + }, + { + "contentType": "movie", + "filename": "poster/square-city-sonata.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Square City Sonata\" (2023).\nGenre: Abstract Musical.\nStory seed: Every corner keeps time. Blocks, windows, crosswalks, and streetlights become a geometric city symphony over one bright afternoon.\nVisual direction: geometric blocks, windows, crosswalks, and streetlights arranged as a flat city symphony\nVisual medium and style: Bauhaus-inspired abstract musical city art, flat geometric blocks, no perspective realism, rhythmic rectangles and circles\nPalette and texture: cream, brick red, cobalt blue, mustard yellow, charcoal\nSafety 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.\nComposition: portrait 2:3 stacked city blocks, windows, and circles arranged like a musical score; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "square-city-sonata", + "title": "Square City Sonata" + }, + { + "contentType": "movie", + "filename": "backdrop/square-city-sonata.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Square City Sonata\" (2023).\nGenre: Abstract Musical.\nStory seed: Every corner keeps time. Blocks, windows, crosswalks, and streetlights become a geometric city symphony over one bright afternoon.\nVisual direction: geometric blocks, windows, crosswalks, and streetlights arranged as a flat city symphony\nVisual medium and style: Bauhaus-inspired abstract musical city art, flat geometric blocks, no perspective realism, rhythmic rectangles and circles\nPalette and texture: cream, brick red, cobalt blue, mustard yellow, charcoal\nSafety 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.\nComposition: wide 16:9 wide geometric city grid with crosswalk rhythms and streetlight circles across a flat afternoon; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "square-city-sonata", + "title": "Square City Sonata" + }, + { + "contentType": "movie", + "filename": "poster/lanterns-under-snow.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Lanterns Under Snow\" (2022).\nGenre: Minimalist Folk Tale.\nStory seed: Small lights travel far. A minimalist winter tale about neighbors carrying warm paper lanterns across a silent hill town.\nVisual direction: neighbors carrying warm paper lanterns across a silent snowy hill town\nVisual medium and style: minimal winter folk illustration, flat shapes, quiet negative space, soft paper texture, restrained detail\nPalette and texture: snow white, ink navy, lantern amber, muted pine green, pale gray\nSafety 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.\nComposition: portrait 2:3 single warm lantern path climbing a snowy hill town with small simple figures; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "lanterns-under-snow", + "title": "Lanterns Under Snow" + }, + { + "contentType": "movie", + "filename": "backdrop/lanterns-under-snow.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional movie: \"Lanterns Under Snow\" (2022).\nGenre: Minimalist Folk Tale.\nStory seed: Small lights travel far. A minimalist winter tale about neighbors carrying warm paper lanterns across a silent hill town.\nVisual direction: neighbors carrying warm paper lanterns across a silent snowy hill town\nVisual medium and style: minimal winter folk illustration, flat shapes, quiet negative space, soft paper texture, restrained detail\nPalette and texture: snow white, ink navy, lantern amber, muted pine green, pale gray\nSafety 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.\nComposition: wide 16:9 wide snowy hillside town with sparse houses, paper lantern dots, and a calm empty sky; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "lanterns-under-snow", + "title": "Lanterns Under Snow" + }, + { + "contentType": "series", + "filename": "poster/skyline-sentinels.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Skyline Sentinels\" (2025).\nGenre: Urban Ensemble Drama.\nStory seed: The city looks up. A young maintenance crew patrols the rooftops, rails, and river bridges of Meridian City.\nVisual direction: a young rooftop rescue team watching rails, bridges, and river lights from high above Meridian City at sunrise\nVisual medium and style: urban infrastructure drama photography, maintenance crew realism, rooftops, rails, bridge cables, sunrise work shift mood\nPalette and texture: concrete gray, safety yellow, sunrise peach, river blue\nSafety 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.\nComposition: portrait 2:3 young maintenance crew on a roof with tools and city geometry, not heroic costumes; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "skyline-sentinels", + "title": "Skyline Sentinels" + }, + { + "contentType": "series", + "filename": "backdrop/skyline-sentinels.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Skyline Sentinels\" (2025).\nGenre: Urban Ensemble Drama.\nStory seed: The city looks up. A young maintenance crew patrols the rooftops, rails, and river bridges of Meridian City.\nVisual direction: a young rooftop rescue team watching rails, bridges, and river lights from high above Meridian City at sunrise\nVisual medium and style: urban infrastructure drama photography, maintenance crew realism, rooftops, rails, bridge cables, sunrise work shift mood\nPalette and texture: concrete gray, safety yellow, sunrise peach, river blue\nSafety 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.\nComposition: wide 16:9 wide rooftop and bridge panorama with work lights and morning haze; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "skyline-sentinels", + "title": "Skyline Sentinels" + }, + { + "contentType": "series", + "filename": "poster/the-aegis-club.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"The Aegis Club\" (2024).\nGenre: Workplace Dramedy.\nStory seed: Legacy is a living room. Retired public servants reopen an old social club and mentor the next generation between neighborhood emergencies.\nVisual direction: retired heroes in a restored neighborhood social club, worn memorabilia, warm lamps, and a new generation training nearby\nVisual medium and style: warm workplace dramedy photography, neighborhood social club interiors, old wood, mismatched chairs, trophies, candid ensemble energy\nPalette and texture: walnut brown, faded green upholstery, brass lamps, soft cream walls\nSafety 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.\nComposition: portrait 2:3 group around an old club table with lived-in props and gentle humor; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "the-aegis-club", + "title": "The Aegis Club" + }, + { + "contentType": "series", + "filename": "backdrop/the-aegis-club.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"The Aegis Club\" (2024).\nGenre: Workplace Dramedy.\nStory seed: Legacy is a living room. Retired public servants reopen an old social club and mentor the next generation between neighborhood emergencies.\nVisual direction: retired heroes in a restored neighborhood social club, worn memorabilia, warm lamps, and a new generation training nearby\nVisual medium and style: warm workplace dramedy photography, neighborhood social club interiors, old wood, mismatched chairs, trophies, candid ensemble energy\nPalette and texture: walnut brown, faded green upholstery, brass lamps, soft cream walls\nSafety 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.\nComposition: wide 16:9 wide social club room with training area, memorabilia, and warm lamps; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "the-aegis-club", + "title": "The Aegis Club" + }, + { + "contentType": "series", + "filename": "poster/starfall-academy.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Starfall Academy\" (2026).\nGenre: Animated Space School.\nStory seed: Homework can save a planet. Cadets with unruly science experiments learn rescue work on a school ship near the asteroid belt.\nVisual direction: cadets with unstable cosmic powers aboard a school ship near the asteroid belt, classroom windows filled with stars\nVisual medium and style: colorful anime-inspired space-school illustration with expressive cadets, school-ship corridors, science experiments, and star windows\nPalette and texture: bubblegum pink, starship white, asteroid gray, neon mint\nSafety 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.\nComposition: portrait 2:3 cadets and floating school equipment in dynamic anime school-club composition; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "starfall-academy", + "title": "Starfall Academy" + }, + { + "contentType": "series", + "filename": "backdrop/starfall-academy.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Starfall Academy\" (2026).\nGenre: Animated Space School.\nStory seed: Homework can save a planet. Cadets with unruly science experiments learn rescue work on a school ship near the asteroid belt.\nVisual direction: cadets with unstable cosmic powers aboard a school ship near the asteroid belt, classroom windows filled with stars\nVisual medium and style: colorful anime-inspired space-school illustration with expressive cadets, school-ship corridors, science experiments, and star windows\nPalette and texture: bubblegum pink, starship white, asteroid gray, neon mint\nSafety 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.\nComposition: wide 16:9 wide school-ship classroom near asteroid belt with desks, windows, and drifting experiment lights; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "starfall-academy", + "title": "Starfall Academy" + }, + { + "contentType": "series", + "filename": "poster/tomorrow-bureau.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Tomorrow Bureau\" (2025).\nGenre: Science Fiction, Thriller.\nStory seed: The future files first. Analysts with one-day foresight prevent disasters while trying not to rewrite their own lives.\nVisual direction: future analysts in a glass-walled operations room sorting one-day-ahead disaster files, clocks and reflections layered across the scene\nVisual medium and style: minimalist bureaucratic sci-fi thriller, glass offices, files, clocks, reflection grids, restrained corporate unease\nPalette and texture: frosted glass, desaturated teal, paper white, warning red pinpoints\nSafety 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.\nComposition: portrait 2:3 analyst silhouette amid files and clocks, clean tense graphic composition; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "tomorrow-bureau", + "title": "Tomorrow Bureau" + }, + { + "contentType": "series", + "filename": "backdrop/tomorrow-bureau.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Tomorrow Bureau\" (2025).\nGenre: Science Fiction, Thriller.\nStory seed: The future files first. Analysts with one-day foresight prevent disasters while trying not to rewrite their own lives.\nVisual direction: future analysts in a glass-walled operations room sorting one-day-ahead disaster files, clocks and reflections layered across the scene\nVisual medium and style: minimalist bureaucratic sci-fi thriller, glass offices, files, clocks, reflection grids, restrained corporate unease\nPalette and texture: frosted glass, desaturated teal, paper white, warning red pinpoints\nSafety 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.\nComposition: wide 16:9 wide operations room with glass walls, layered clock reflections, and negative space; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "tomorrow-bureau", + "title": "Tomorrow Bureau" + }, + { + "contentType": "series", + "filename": "poster/alley-mask.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Alley Mask\" (2023).\nGenre: Street Noir Mystery.\nStory seed: Every shortcut has a secret. A bike courier investigates strange coded signals appearing in the city alleys.\nVisual direction: a bike courier stepping through deep city shadows while following strange signal glyphs in narrow alleys after rain\nVisual medium and style: neo-noir street photography, bicycle courier realism, coded light marks, wet alleys, handheld camera energy\nPalette and texture: wet asphalt, sodium yellow, deep green shadows, chalk-white signal marks\nSafety 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.\nComposition: portrait 2:3 bike courier and alley geometry, face partly obscured by ordinary rain gear, grounded mystery; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "alley-mask", + "title": "Alley Mask" + }, + { + "contentType": "series", + "filename": "backdrop/alley-mask.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Alley Mask\" (2023).\nGenre: Street Noir Mystery.\nStory seed: Every shortcut has a secret. A bike courier investigates strange coded signals appearing in the city alleys.\nVisual direction: a bike courier stepping through deep city shadows while following strange signal glyphs in narrow alleys after rain\nVisual medium and style: neo-noir street photography, bicycle courier realism, coded light marks, wet alleys, handheld camera energy\nPalette and texture: wet asphalt, sodium yellow, deep green shadows, chalk-white signal marks\nSafety 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.\nComposition: wide 16:9 wide narrow alley after rain with coded signal glyphs and long perspective; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "alley-mask", + "title": "Alley Mask" + }, + { + "contentType": "series", + "filename": "poster/neon-sparrow.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Neon Sparrow\" (2024).\nGenre: Cyberpunk Mystery.\nStory seed: Small wings, bright city. A rooftop messenger follows glowing clues that only appear after midnight rain.\nVisual direction: a rooftop messenger following glowing clues that appear in midnight rain, neon reflections and small bright wing motifs\nVisual medium and style: stylized cyberpunk graphic novel art, rooftop rain, neon clue trails, birdlike light motifs, bold ink shapes\nPalette and texture: magenta neon, cyan rain, black ink, pale green glow\nSafety 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.\nComposition: portrait 2:3 rooftop messenger and sparrow-like light trail in bold graphic silhouette; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "neon-sparrow", + "title": "Neon Sparrow" + }, + { + "contentType": "series", + "filename": "backdrop/neon-sparrow.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Neon Sparrow\" (2024).\nGenre: Cyberpunk Mystery.\nStory seed: Small wings, bright city. A rooftop messenger follows glowing clues that only appear after midnight rain.\nVisual direction: a rooftop messenger following glowing clues that appear in midnight rain, neon reflections and small bright wing motifs\nVisual medium and style: stylized cyberpunk graphic novel art, rooftop rain, neon clue trails, birdlike light motifs, bold ink shapes\nPalette and texture: magenta neon, cyan rain, black ink, pale green glow\nSafety 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.\nComposition: wide 16:9 wide rain-soaked rooftop cityscape with glowing clues and open sky; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "neon-sparrow", + "title": "Neon Sparrow" + }, + { + "contentType": "series", + "filename": "poster/cape-makers.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Cape Makers\" (2024).\nGenre: Mockumentary, Design.\nStory seed: Every symbol is handmade. A fictional behind-the-scenes series about the tailors, engineers, and artists who design stage gear.\nVisual direction: tailors, engineers, and artists building original stage gear in a busy design studio, fabric tests and hand-built emblems everywhere\nVisual medium and style: behind-the-scenes craft docuseries photography, sewing machines, engineering benches, stage fabric, candid hands-at-work details\nPalette and texture: indigo cloth, warm workshop bulbs, steel tools, chalk marks\nSafety 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.\nComposition: portrait 2:3 tailors and engineers at work around stage gear, tactile documentary composition; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "cape-makers", + "title": "Cape Makers" + }, + { + "contentType": "series", + "filename": "backdrop/cape-makers.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Cape Makers\" (2024).\nGenre: Mockumentary, Design.\nStory seed: Every symbol is handmade. A fictional behind-the-scenes series about the tailors, engineers, and artists who design stage gear.\nVisual direction: tailors, engineers, and artists building original stage gear in a busy design studio, fabric tests and hand-built emblems everywhere\nVisual medium and style: behind-the-scenes craft docuseries photography, sewing machines, engineering benches, stage fabric, candid hands-at-work details\nPalette and texture: indigo cloth, warm workshop bulbs, steel tools, chalk marks\nSafety 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.\nComposition: wide 16:9 wide busy design studio with fabric rolls, benches, sketches without readable text; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "cape-makers", + "title": "Cape Makers" + }, + { + "contentType": "series", + "filename": "poster/citizens-of-the-beacon.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Citizens of the Beacon\" (2022).\nGenre: Mockumentary, Human Interest.\nStory seed: A city can be a hero too. Ordinary residents tell the stories behind a citywide rescue network built after the first beacon lit.\nVisual direction: ordinary residents maintaining a citywide rescue network after the first beacon, hopeful faces lit by practical signal lamps\nVisual medium and style: quiet social documentary photography, ordinary residents, practical signal lamps, neighborhood streets, compassionate realism\nPalette and texture: lamp amber, dusk blue, concrete gray, muted household colors\nSafety 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.\nComposition: portrait 2:3 ordinary residents around a beacon lamp, intimate human-interest portrait; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "citizens-of-the-beacon", + "title": "Citizens of the Beacon" + }, + { + "contentType": "series", + "filename": "backdrop/citizens-of-the-beacon.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Citizens of the Beacon\" (2022).\nGenre: Mockumentary, Human Interest.\nStory seed: A city can be a hero too. Ordinary residents tell the stories behind a citywide rescue network built after the first beacon lit.\nVisual direction: ordinary residents maintaining a citywide rescue network after the first beacon, hopeful faces lit by practical signal lamps\nVisual medium and style: quiet social documentary photography, ordinary residents, practical signal lamps, neighborhood streets, compassionate realism\nPalette and texture: lamp amber, dusk blue, concrete gray, muted household colors\nSafety 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.\nComposition: wide 16:9 wide neighborhood rescue network at dusk with lamps in windows and street corners; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "citizens-of-the-beacon", + "title": "Citizens of the Beacon" + }, + { + "contentType": "series", + "filename": "poster/bubble-street-workshop.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Bubble Street Workshop\" (2026).\nGenre: Flat Kids Craft Cartoon.\nStory seed: Round ideas roll further. Neighbors build simple inventions from circles, paper, and string in a cheerful street workshop.\nVisual direction: neighbors building simple inventions from circles, paper, string, and bright workshop tables\nVisual medium and style: flat kids craft cartoon, simple circles and paper shapes, playful handmade textures, bright but not busy\nPalette and texture: bubblegum pink, craft paper tan, sky blue, crayon green, sunflower yellow\nSafety 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.\nComposition: portrait 2:3 round neighbors building a simple invention from paper, string, and circles; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "bubble-street-workshop", + "title": "Bubble Street Workshop" + }, + { + "contentType": "series", + "filename": "backdrop/bubble-street-workshop.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Bubble Street Workshop\" (2026).\nGenre: Flat Kids Craft Cartoon.\nStory seed: Round ideas roll further. Neighbors build simple inventions from circles, paper, and string in a cheerful street workshop.\nVisual direction: neighbors building simple inventions from circles, paper, string, and bright workshop tables\nVisual medium and style: flat kids craft cartoon, simple circles and paper shapes, playful handmade textures, bright but not busy\nPalette and texture: bubblegum pink, craft paper tan, sky blue, crayon green, sunflower yellow\nSafety 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.\nComposition: wide 16:9 wide cheerful street workshop with tables, bubbles, paper circles, and open simple storefront shapes; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "bubble-street-workshop", + "title": "Bubble Street Workshop" + }, + { + "contentType": "series", + "filename": "poster/flatland-detectives.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Flatland Detectives\" (2025).\nGenre: Geometric Mystery Comedy.\nStory seed: Every clue has a shape. Two geometric detectives solve gentle neighborhood mysteries using maps, shadows, and shape clues.\nVisual direction: two geometric detectives solving gentle neighborhood mysteries using maps, shadows, and shape clues\nVisual medium and style: flat geometric mystery comedy, shape-language characters, simple shadows, crisp poster design, no realism\nPalette and texture: warm gray, detective orange, navy, cream, clue green\nSafety 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.\nComposition: portrait 2:3 two geometric detectives inspecting a simple shape clue with playful noir lighting; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "flatland-detectives", + "title": "Flatland Detectives" + }, + { + "contentType": "series", + "filename": "backdrop/flatland-detectives.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Flatland Detectives\" (2025).\nGenre: Geometric Mystery Comedy.\nStory seed: Every clue has a shape. Two geometric detectives solve gentle neighborhood mysteries using maps, shadows, and shape clues.\nVisual direction: two geometric detectives solving gentle neighborhood mysteries using maps, shadows, and shape clues\nVisual medium and style: flat geometric mystery comedy, shape-language characters, simple shadows, crisp poster design, no realism\nPalette and texture: warm gray, detective orange, navy, cream, clue green\nSafety 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.\nComposition: wide 16:9 wide flat neighborhood map scene with shadows, arrows, and geometric clues; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "flatland-detectives", + "title": "Flatland Detectives" + }, + { + "contentType": "series", + "filename": "poster/color-orchard.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Color Orchard\" (2024).\nGenre: Preschool Nature Cartoon.\nStory seed: Pick a color, grow a song. A preschool orchard changes color each morning while tiny caretakers learn seasons, sharing, and sound.\nVisual direction: a preschool orchard changing color each morning while tiny caretakers learn seasons and sound\nVisual medium and style: preschool nature cartoon, very simple flat rounded shapes, soft friendly faces implied by objects, low detail\nPalette and texture: apple red, leaf green, pear yellow, sky blue, soft soil brown\nSafety 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.\nComposition: portrait 2:3 tiny caretakers under one large color-changing tree, simple fruit shapes and musical notes without text; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "color-orchard", + "title": "Color Orchard" + }, + { + "contentType": "series", + "filename": "backdrop/color-orchard.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Color Orchard\" (2024).\nGenre: Preschool Nature Cartoon.\nStory seed: Pick a color, grow a song. A preschool orchard changes color each morning while tiny caretakers learn seasons, sharing, and sound.\nVisual direction: a preschool orchard changing color each morning while tiny caretakers learn seasons and sound\nVisual medium and style: preschool nature cartoon, very simple flat rounded shapes, soft friendly faces implied by objects, low detail\nPalette and texture: apple red, leaf green, pear yellow, sky blue, soft soil brown\nSafety 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.\nComposition: wide 16:9 wide gentle orchard made of rounded flat trees changing colors across the morning; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "color-orchard", + "title": "Color Orchard" + }, + { + "contentType": "series", + "filename": "poster/museum-of-little-robots.png", + "kind": "poster", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: portrait cover/poster PNG for a fictional streaming demo catalog.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Museum of Little Robots\" (2025).\nGenre: Flat Retro-Future Cartoon.\nStory seed: Tiny gears, big curiosity. Small helpful robots wake up after closing time to arrange exhibits in a quiet retro-future museum.\nVisual direction: small helpful robots arranging exhibits after closing time in a quiet retro-future museum\nVisual medium and style: flat retro-future cartoon, simple rounded robots, clean museum shapes, limited details, soft mid-century geometry\nPalette and texture: dusty teal, warm cream, tomato red, brushed metal gray, muted orange\nSafety 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.\nComposition: portrait 2:3 small helpful robots arranging one oversized exhibit in a quiet museum hall; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.", + "quality": "medium", + "size": "1024x1536", + "slug": "museum-of-little-robots", + "title": "Museum of Little Robots" + }, + { + "contentType": "series", + "filename": "backdrop/museum-of-little-robots.png", + "kind": "backdrop", + "manualApprovalStatus": "pending-human-review", + "model": "gpt-image-2", + "prompt": "Use case: ads-marketing\nAsset type: landscape backdrop PNG for a fictional streaming demo details screen.\nCreate original fictional demo artwork for IPTVnator release screenshots.\nFictional series: \"Museum of Little Robots\" (2025).\nGenre: Flat Retro-Future Cartoon.\nStory seed: Tiny gears, big curiosity. Small helpful robots wake up after closing time to arrange exhibits in a quiet retro-future museum.\nVisual direction: small helpful robots arranging exhibits after closing time in a quiet retro-future museum\nVisual medium and style: flat retro-future cartoon, simple rounded robots, clean museum shapes, limited details, soft mid-century geometry\nPalette and texture: dusty teal, warm cream, tomato red, brushed metal gray, muted orange\nSafety 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.\nComposition: wide 16:9 wide retro-future museum after closing with tiny robots, plinths, and simple geometric exhibits; darker edges and open negative space so app UI overlays remain readable; no text or logos.", + "quality": "medium", + "size": "1536x864", + "slug": "museum-of-little-robots", + "title": "Museum of Little Robots" + } + ], + "model": "gpt-image-2", + "outputFormat": "png", + "version": 1 +} diff --git a/apps/xtream-mock-server/public/marketing/poster/beacon-boy-and-the-clockwork-garden.png b/apps/xtream-mock-server/public/marketing/poster/beacon-boy-and-the-clockwork-garden.png new file mode 100644 index 000000000..ae9d1d5c6 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/beacon-boy-and-the-clockwork-garden.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/captain-kindling.png b/apps/xtream-mock-server/public/marketing/poster/captain-kindling.png new file mode 100644 index 000000000..4eb9a3791 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/captain-kindling.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/crimson-skylark.png b/apps/xtream-mock-server/public/marketing/poster/crimson-skylark.png new file mode 100644 index 000000000..6f8d0812a Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/crimson-skylark.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/dot-and-the-cloud-train.png b/apps/xtream-mock-server/public/marketing/poster/dot-and-the-cloud-train.png new file mode 100644 index 000000000..6df32fea7 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/dot-and-the-cloud-train.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/masks-of-tomorrow.png b/apps/xtream-mock-server/public/marketing/poster/masks-of-tomorrow.png new file mode 100644 index 000000000..84fca4e6b Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/masks-of-tomorrow.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/midnight-mantle.png b/apps/xtream-mock-server/public/marketing/poster/midnight-mantle.png new file mode 100644 index 000000000..a0f227f0a Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/midnight-mantle.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/nova-relay.png b/apps/xtream-mock-server/public/marketing/poster/nova-relay.png new file mode 100644 index 000000000..e8c0ab416 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/nova-relay.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/orbit-of-atlas.png b/apps/xtream-mock-server/public/marketing/poster/orbit-of-atlas.png new file mode 100644 index 000000000..ae82c50a8 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/orbit-of-atlas.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/paper-moon-parade.png b/apps/xtream-mock-server/public/marketing/poster/paper-moon-parade.png new file mode 100644 index 000000000..66b221dc1 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/paper-moon-parade.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/quantum-comet.png b/apps/xtream-mock-server/public/marketing/poster/quantum-comet.png new file mode 100644 index 000000000..3d6fc3859 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/quantum-comet.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/signal-tower-seven.png b/apps/xtream-mock-server/public/marketing/poster/signal-tower-seven.png new file mode 100644 index 000000000..749fc2d9b Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/signal-tower-seven.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/the-invisible-saturday.png b/apps/xtream-mock-server/public/marketing/poster/the-invisible-saturday.png new file mode 100644 index 000000000..3df042781 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/the-invisible-saturday.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/the-voltage-guard.png b/apps/xtream-mock-server/public/marketing/poster/the-voltage-guard.png new file mode 100644 index 000000000..fb5dd0b9e Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/the-voltage-guard.png differ diff --git a/apps/xtream-mock-server/public/marketing/poster/tiny-planet-picnic.png b/apps/xtream-mock-server/public/marketing/poster/tiny-planet-picnic.png new file mode 100644 index 000000000..20d465eb4 Binary files /dev/null and b/apps/xtream-mock-server/public/marketing/poster/tiny-planet-picnic.png differ diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index 474facd2a..c0b07513e 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -11,6 +11,11 @@ import { import { generateVodStreams, generateVodDetails, RawVodStream, RawVodDetails } from './generators/vod.generator.js'; import { generateSeriesItems, generateSeriesInfo, RawSeriesItem, RawSeriesInfo } from './generators/series.generator.js'; import { RawCategory } from './generators/categories.generator.js'; +import { + buildMarketingPortalFixture, + buildMarketingSeriesInfo, + buildMarketingVodDetails, +} from './generators/marketing.generator.js'; import { getScenario, ScenarioConfig } from './scenarios.js'; export interface PortalData { @@ -34,6 +39,27 @@ function generatePortalData(username: string, password: string): PortalData { const { categoryCount, itemsPerCategory, seasonsPerSeries, episodesPerSeason } = scenario; + if (scenario.marketingFixture) { + const marketingFixture = buildMarketingPortalFixture(); + for (const s of marketingFixture.seriesItems) { + if (!seriesInfoCache.has(s.series_id)) { + seriesInfoCache.set( + s.series_id, + buildMarketingSeriesInfo( + s, + seasonsPerSeries, + episodesPerSeason + ) + ); + } + } + + return { + scenario, + ...marketingFixture, + }; + } + let liveCategories = generateCategories('live', categoryCount.live); const vodCategories = generateCategories('vod', categoryCount.vod); const seriesCategories = generateCategories('series', categoryCount.series); @@ -88,7 +114,12 @@ export function getVodDetails(username: string, password: string, vodId: number) vodDetailsCache.set(vodId, { info: [] }); return vodDetailsCache.get(vodId) ?? null; } - vodDetailsCache.set(vodId, generateVodDetails(stream)); + vodDetailsCache.set( + vodId, + data.scenario.marketingFixture + ? buildMarketingVodDetails(stream) + : generateVodDetails(stream) + ); } return vodDetailsCache.get(vodId) ?? null; } @@ -99,7 +130,20 @@ export function getSeriesInfo(username: string, password: string, seriesId: numb const series = data.seriesItems.find(s => s.series_id === seriesId); if (!series) return null; const scenario = getScenario(username, password); - seriesInfoCache.set(seriesId, generateSeriesInfo(series, scenario.seasonsPerSeries, scenario.episodesPerSeason)); + seriesInfoCache.set( + seriesId, + scenario.marketingFixture + ? buildMarketingSeriesInfo( + series, + scenario.seasonsPerSeries, + scenario.episodesPerSeason + ) + : generateSeriesInfo( + series, + scenario.seasonsPerSeries, + scenario.episodesPerSeason + ) + ); } return seriesInfoCache.get(seriesId) ?? null; } diff --git a/apps/xtream-mock-server/src/app/generators/marketing.generator.ts b/apps/xtream-mock-server/src/app/generators/marketing.generator.ts new file mode 100644 index 000000000..000cb3fd6 --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/marketing.generator.ts @@ -0,0 +1,1032 @@ +import { RawCategory } from './categories.generator.js'; +import { + buildEpgListing, + RawEpgListing, + RawLiveStream, +} from './live.generator.js'; +import { RawSeriesInfo, RawSeriesItem } from './series.generator.js'; +import { RawVodDetails, RawVodStream } from './vod.generator.js'; + +type MarketingAssetKind = 'backdrop' | 'episode' | 'logo' | 'poster' | 'season'; + +type MarketingMovie = { + actors: string; + categoryId: string; + description: string; + director: string; + genre: string; + name: string; + rating: number; + tagline: string; + year: number; +}; + +type MarketingSeries = { + actors: string; + categoryId: string; + description: string; + director: string; + genre: string; + name: string; + rating: number; + tagline: string; + year: number; +}; + +type MarketingLiveChannel = { + categoryId: string; + epgTitles: string[]; + name: string; +}; + +export type MarketingArtworkFixture = { + contentType: 'movie' | 'series'; + description: string; + genre: string; + name: string; + slug: string; + tagline: string; + year: number; +}; + +export type MarketingPortalFixture = { + liveCategories: RawCategory[]; + liveStreams: RawLiveStream[]; + vodCategories: RawCategory[]; + vodStreams: RawVodStream[]; + seriesCategories: RawCategory[]; + seriesItems: RawSeriesItem[]; + epgListingsByStreamId: Map; +}; + +const MARKETING_LIVE_STREAM_ID_BASE = 52_000; +const MARKETING_VOD_STREAM_ID_BASE = 62_000; +const MARKETING_SERIES_ID_BASE = 72_000; +const MARKETING_EPISODE_ID_BASE = 82_000; +const ADDED_BASE = 1_777_000_000; + +const LIVE_CATEGORIES: RawCategory[] = [ + { category_id: '5101', category_name: 'Newsroom', parent_id: 0 }, + { category_id: '5102', category_name: 'Sports & Motion', parent_id: 0 }, + { category_id: '5103', category_name: 'Family Channels', parent_id: 0 }, + { category_id: '5104', category_name: 'Culture & Docs', parent_id: 0 }, +]; + +const VOD_CATEGORIES: RawCategory[] = [ + { category_id: '5201', category_name: 'Action & Mystery', parent_id: 0 }, + { + category_id: '5202', + category_name: 'Cosmic & Future Worlds', + parent_id: 0, + }, + { category_id: '5203', category_name: 'Family Animation', parent_id: 0 }, + { category_id: '5204', category_name: 'Documentary & Drama', parent_id: 0 }, +]; + +const SERIES_CATEGORIES: RawCategory[] = [ + { category_id: '5301', category_name: 'Urban Drama', parent_id: 0 }, + { category_id: '5302', category_name: 'Speculative Series', parent_id: 0 }, + { category_id: '5303', category_name: 'Noir & Neon', parent_id: 0 }, + { category_id: '5304', category_name: 'Creative Docs', parent_id: 0 }, +]; + +const LIVE_CHANNELS: MarketingLiveChannel[] = [ + { + categoryId: '5101', + name: 'Aurora News', + epgTitles: ['Morning Briefing', 'City Desk', 'Global Window'], + }, + { + categoryId: '5101', + name: 'Civic One', + epgTitles: ['Town Hall Live', 'Policy Today', 'Open Forum'], + }, + { + categoryId: '5102', + name: 'Fieldside Sports', + epgTitles: ['Training Ground', 'Matchday Studio', 'Final Whistle'], + }, + { + categoryId: '5102', + name: 'Motion Arena', + epgTitles: ['Court Vision', 'Trackside', 'Night Highlights'], + }, + { + categoryId: '5103', + name: 'Horizon Kids', + epgTitles: ['Rocket Workshop', 'Tiny Explorers', 'Story Lantern'], + }, + { + categoryId: '5103', + name: 'Kitchen Lab', + epgTitles: ['Breakfast Builders', 'Family Table', 'Sweet Science'], + }, + { + categoryId: '5104', + name: 'Atlas Docs', + epgTitles: ['Ocean Notes', 'Museum Hour', 'Wide Angle'], + }, + { + categoryId: '5104', + name: 'Night Music', + epgTitles: ['Studio Session', 'Late Set', 'Ambient City'], + }, +]; + +const MOVIES: MarketingMovie[] = [ + { + categoryId: '5201', + name: 'Crimson Skylark', + description: + 'A high-altitude rescue courier follows sunrise signals through the storm routes above Meridian City.', + director: 'Ari Vale', + actors: 'Mara Sol, Theo Quill, Iris Vale, Nico Reed', + genre: 'Aerial Rescue Adventure', + rating: 8.5, + tagline: 'Every sunrise needs a signal.', + year: 2026, + }, + { + categoryId: '5201', + name: 'The Voltage Guard', + description: + 'Four transit workers race to keep the night trains moving as a thunderstorm turns the city grid unstable.', + director: 'Noah Kade', + actors: 'Lena Voss, Mateo Rinn, Cora Vale, Juno Pike', + genre: 'Disaster Action Thriller', + rating: 8.1, + tagline: 'Power is only heroic when it is shared.', + year: 2025, + }, + { + categoryId: '5201', + name: 'Midnight Mantle', + description: + 'A retired stage magician investigates a living cloak rumor haunting the old theatre district after dark.', + director: 'Selene Park', + actors: 'Iris Vale, Bram Cole, Tessa Moon, Rafi North', + genre: 'Gothic Mystery', + rating: 8.0, + tagline: 'The city has one last trick.', + year: 2024, + }, + { + categoryId: '5202', + name: 'Quantum Comet', + description: + 'An orbital cartographer rides a fractured comet trail to stop a time-lost armada.', + director: 'Vera Lin', + actors: 'Kai Mercer, Lina Storm, Ren Sato, Nova Bell', + genre: 'Cosmic Science Fiction', + rating: 8.7, + tagline: 'Faster than tomorrow.', + year: 2026, + }, + { + categoryId: '5202', + name: 'Orbit of Atlas', + description: + 'A rescue pilot navigates a fractured moon orbit long enough for a remote colony to evacuate.', + director: 'Noah Kade', + actors: 'Theo Quill, Mira Stone, Pax Arden, Elle Fenn', + genre: 'Cosmic Survival Adventure', + rating: 7.9, + tagline: 'Hold the sky.', + year: 2025, + }, + { + categoryId: '5202', + name: 'Nova Relay', + description: + 'Twin signal runners cross a star bridge to deliver the last warning before a solar network goes dark.', + director: 'Ari Vale', + actors: 'Cora Vale, Juno Pike, Bram Cole, Kai Mercer', + genre: 'Space Opera', + rating: 8.2, + tagline: 'The message arrives in light.', + year: 2023, + }, + { + categoryId: '5203', + name: 'Beacon Boy and the Clockwork Garden', + description: + 'A young inventor and his grandmother awaken a garden of mechanical guardians beneath the city park.', + director: 'Selene Park', + actors: 'Milo Grey, Anika Fern, Tessa Moon, Rafi North', + genre: 'Animated Family Fantasy', + rating: 8.3, + tagline: 'Small heroes grow tall.', + year: 2026, + }, + { + categoryId: '5203', + name: 'The Invisible Saturday', + description: + 'Two siblings learn to turn invisible every Saturday and use the gift to save their neighborhood fair.', + director: 'Mira Stone', + actors: 'Pax Arden, Elle Fenn, Milo Grey, Lina Storm', + genre: 'Family, Adventure', + rating: 7.8, + tagline: 'Seen or unseen, courage counts.', + year: 2024, + }, + { + categoryId: '5203', + name: 'Captain Kindling', + description: + 'A camp counselor with ember-bright powers teaches a team of misfits how to protect a forest town.', + director: 'Vera Lin', + actors: 'Nico Reed, Anika Fern, Theo Quill, Cora Vale', + genre: 'Animated Family Comedy', + rating: 7.5, + tagline: 'A spark can lead the way.', + year: 2022, + }, + { + categoryId: '5203', + name: 'Paper Moon Parade', + description: + 'A paper-cut moon rolls through a tiny town and invites every shy child to join a midnight parade.', + director: 'Iris Vale', + actors: 'Milo Grey, Anika Fern, Nova Bell, Elle Fenn', + genre: 'Flat Kids Fantasy', + rating: 8.1, + tagline: 'Fold the night into wonder.', + year: 2026, + }, + { + categoryId: '5203', + name: 'Dot and the Cloud Train', + description: + 'A small dot boards a soft cloud train and learns the colors of weather with a group of round little friends.', + director: 'Mira Stone', + actors: 'Pax Arden, Elle Fenn, Milo Grey, Lina Storm', + genre: 'Preschool Cartoon Adventure', + rating: 7.9, + tagline: 'Every stop is a color.', + year: 2025, + }, + { + categoryId: '5203', + name: 'Tiny Planet Picnic', + description: + 'Three children prepare a picnic on a pocket-sized planet where flowers orbit like friendly satellites.', + director: 'Selene Park', + actors: 'Anika Fern, Milo Grey, Tessa Moon, Pax Arden', + genre: 'Simple Family Sci-Fi Cartoon', + rating: 8.0, + tagline: 'Pack a lunch for the stars.', + year: 2024, + }, + { + categoryId: '5204', + name: 'Masks of Tomorrow', + description: + 'A fictional documentary profile of costume makers, signal engineers, and citizens building a city festival from scratch.', + director: 'Juno Pike', + actors: 'Mara Sol, Ren Sato, Iris Vale, Mateo Rinn', + genre: 'Creative Documentary', + rating: 8.4, + tagline: 'Behind every emblem is a city.', + year: 2025, + }, + { + categoryId: '5204', + name: 'Signal Tower Seven', + description: + 'A retro-styled chronicle of radio operators who coordinate mountain rescues during a record winter blackout.', + director: 'Rafi North', + actors: 'Lena Voss, Kai Mercer, Tessa Moon, Bram Cole', + genre: 'Retro Rescue Drama', + rating: 8.2, + tagline: 'Not all heroes fly.', + year: 2023, + }, + { + categoryId: '5204', + name: 'The League of Lanterns', + description: + 'A narrated archive of a volunteer lantern network that lights safe paths during impossible blackouts.', + director: 'Mira Stone', + actors: 'Nova Bell, Milo Grey, Elle Fenn, Ren Sato', + genre: 'Human Interest Documentary', + rating: 7.7, + tagline: 'When the grid fails, people glow.', + year: 2024, + }, + { + categoryId: '5204', + name: 'The Quiet Aquarium', + description: + 'An abstract night watch follows drifting shapes, quiet visitors, and glowing tanks inside a closed city aquarium.', + director: 'Vera Lin', + actors: 'Nova Bell, Ren Sato, Cora Vale, Kai Mercer', + genre: 'Abstract Meditative Drama', + rating: 8.3, + tagline: 'Silence has currents.', + year: 2026, + }, + { + categoryId: '5204', + name: 'Square City Sonata', + description: + 'Blocks, windows, crosswalks, and streetlights become a geometric city symphony over one bright afternoon.', + director: 'Rafi North', + actors: 'Lena Voss, Theo Quill, Mira Stone, Bram Cole', + genre: 'Abstract Musical', + rating: 7.6, + tagline: 'Every corner keeps time.', + year: 2023, + }, + { + categoryId: '5204', + name: 'Lanterns Under Snow', + description: + 'A minimalist winter tale about neighbors carrying warm paper lanterns across a silent hill town.', + director: 'Noah Kade', + actors: 'Milo Grey, Nova Bell, Lena Voss, Rafi North', + genre: 'Minimalist Folk Tale', + rating: 8.2, + tagline: 'Small lights travel far.', + year: 2022, + }, +]; + +const SERIES: MarketingSeries[] = [ + { + categoryId: '5301', + name: 'Skyline Sentinels', + description: + 'A young maintenance crew patrols the rooftops, rails, and river bridges of Meridian City.', + director: 'Ari Vale', + actors: 'Mara Sol, Theo Quill, Juno Pike, Ren Sato', + genre: 'Urban Ensemble Drama', + rating: 8.5, + tagline: 'The city looks up.', + year: 2025, + }, + { + categoryId: '5301', + name: 'The Aegis Club', + description: + 'Retired public servants reopen an old social club and mentor the next generation between neighborhood emergencies.', + director: 'Selene Park', + actors: 'Iris Vale, Bram Cole, Lina Storm, Mateo Rinn', + genre: 'Workplace Dramedy', + rating: 8.1, + tagline: 'Legacy is a living room.', + year: 2024, + }, + { + categoryId: '5302', + name: 'Starfall Academy', + description: + 'Cadets with unruly science experiments learn rescue work on a school ship near the asteroid belt.', + director: 'Vera Lin', + actors: 'Kai Mercer, Nova Bell, Cora Vale, Pax Arden', + genre: 'Animated Space School', + rating: 8.8, + tagline: 'Homework can save a planet.', + year: 2026, + }, + { + categoryId: '5302', + name: 'Tomorrow Bureau', + description: + 'Analysts with one-day foresight prevent disasters while trying not to rewrite their own lives.', + director: 'Noah Kade', + actors: 'Lena Voss, Theo Quill, Mira Stone, Rafi North', + genre: 'Science Fiction, Thriller', + rating: 8.2, + tagline: 'The future files first.', + year: 2025, + }, + { + categoryId: '5303', + name: 'Alley Mask', + description: + 'A bike courier investigates strange coded signals appearing in the city alleys.', + director: 'Mira Stone', + actors: 'Nico Reed, Anika Fern, Iris Vale, Mateo Rinn', + genre: 'Street Noir Mystery', + rating: 7.9, + tagline: 'Every shortcut has a secret.', + year: 2023, + }, + { + categoryId: '5303', + name: 'Neon Sparrow', + description: + 'A rooftop messenger follows glowing clues that only appear after midnight rain.', + director: 'Rafi North', + actors: 'Elle Fenn, Bram Cole, Cora Vale, Juno Pike', + genre: 'Cyberpunk Mystery', + rating: 7.8, + tagline: 'Small wings, bright city.', + year: 2024, + }, + { + categoryId: '5304', + name: 'Cape Makers', + description: + 'A fictional behind-the-scenes series about the tailors, engineers, and artists who design stage gear.', + director: 'Juno Pike', + actors: 'Mara Sol, Tessa Moon, Pax Arden, Ren Sato', + genre: 'Mockumentary, Design', + rating: 8.4, + tagline: 'Every symbol is handmade.', + year: 2024, + }, + { + categoryId: '5304', + name: 'Citizens of the Beacon', + description: + 'Ordinary residents tell the stories behind a citywide rescue network built after the first beacon lit.', + director: 'Noah Kade', + actors: 'Milo Grey, Nova Bell, Lena Voss, Rafi North', + genre: 'Mockumentary, Human Interest', + rating: 8.0, + tagline: 'A city can be a hero too.', + year: 2022, + }, + { + categoryId: '5304', + name: 'Bubble Street Workshop', + description: + 'Neighbors build simple inventions from circles, paper, and string in a cheerful street workshop.', + director: 'Iris Vale', + actors: 'Anika Fern, Milo Grey, Pax Arden, Elle Fenn', + genre: 'Flat Kids Craft Cartoon', + rating: 7.9, + tagline: 'Round ideas roll further.', + year: 2026, + }, + { + categoryId: '5303', + name: 'Flatland Detectives', + description: + 'Two geometric detectives solve gentle neighborhood mysteries using maps, shadows, and shape clues.', + director: 'Mira Stone', + actors: 'Nico Reed, Cora Vale, Juno Pike, Bram Cole', + genre: 'Geometric Mystery Comedy', + rating: 7.7, + tagline: 'Every clue has a shape.', + year: 2025, + }, + { + categoryId: '5302', + name: 'Color Orchard', + description: + 'A preschool orchard changes color each morning while tiny caretakers learn seasons, sharing, and sound.', + director: 'Selene Park', + actors: 'Milo Grey, Nova Bell, Tessa Moon, Elle Fenn', + genre: 'Preschool Nature Cartoon', + rating: 8.1, + tagline: 'Pick a color, grow a song.', + year: 2024, + }, + { + categoryId: '5302', + name: 'Museum of Little Robots', + description: + 'Small helpful robots wake up after closing time to arrange exhibits in a quiet retro-future museum.', + director: 'Vera Lin', + actors: 'Kai Mercer, Lina Storm, Ren Sato, Nova Bell', + genre: 'Flat Retro-Future Cartoon', + rating: 8.0, + tagline: 'Tiny gears, big curiosity.', + year: 2025, + }, +]; + +export function buildMarketingPortalFixture(): MarketingPortalFixture { + const liveStreams = LIVE_CHANNELS.map((channel, index) => + buildMarketingLiveStream(channel, index) + ); + const vodStreams = MOVIES.map((movie, index) => + buildMarketingVodStream(movie, index) + ); + const seriesItems = SERIES.map((series, index) => + buildMarketingSeriesItem(series, index) + ); + const epgListingsByStreamId = new Map(); + + liveStreams.forEach((stream, index) => { + epgListingsByStreamId.set( + stream.stream_id, + buildMarketingEpgListings(stream, LIVE_CHANNELS[index].epgTitles) + ); + }); + + return { + liveCategories: LIVE_CATEGORIES, + liveStreams, + vodCategories: VOD_CATEGORIES, + vodStreams, + seriesCategories: SERIES_CATEGORIES, + seriesItems, + epgListingsByStreamId, + }; +} + +export function listMarketingArtworkFixtures(): MarketingArtworkFixture[] { + return [ + ...MOVIES.map((movie) => ({ + contentType: 'movie' as const, + description: movie.description, + genre: movie.genre, + name: movie.name, + slug: slugify(movie.name), + tagline: movie.tagline, + year: movie.year, + })), + ...SERIES.map((series) => ({ + contentType: 'series' as const, + description: series.description, + genre: series.genre, + name: series.name, + slug: slugify(series.name), + tagline: series.tagline, + year: series.year, + })), + ]; +} + +export function buildMarketingVodDetails(stream: RawVodStream): RawVodDetails { + const movie = MOVIES.find((item) => item.name === stream.name) ?? MOVIES[0]; + const durationSecs = 6_840 + (stream.stream_id % 5) * 600; + const hours = Math.floor(durationSecs / 3600); + const minutes = Math.floor((durationSecs % 3600) / 60); + + return { + info: { + kinopoisk_url: '', + tmdb_id: stream.stream_id, + name: movie.name, + o_name: movie.name, + cover_big: marketingAssetUrl('poster', movie.name, '500x750'), + movie_image: marketingAssetUrl('poster', movie.name, '300x450'), + releasedate: `${movie.year}-04-18`, + episode_run_time: durationSecs, + youtube_trailer: '', + director: movie.director, + actors: movie.actors, + cast: movie.actors, + description: `${movie.tagline} ${movie.description}`, + plot: `${movie.tagline} ${movie.description}`, + age: '12', + mpaa_rating: 'PG-13', + rating_count_kinopoisk: 12_400 + stream.stream_id, + country: 'Meridian Union', + genre: movie.genre, + backdrop_path: [ + marketingAssetUrl('backdrop', movie.name, '1280x720'), + ], + duration_secs: durationSecs, + duration: `${hours}h ${minutes}min`, + video: ['H.264'], + audio: ['AAC'], + bitrate: 4_200 + (stream.stream_id % 6) * 400, + rating: movie.rating, + rating_kinopoisk: movie.rating.toFixed(1), + rating_imdb: movie.rating.toFixed(1), + }, + movie_data: { + stream_id: stream.stream_id, + name: movie.name, + added: stream.added, + category_id: stream.category_id, + container_extension: stream.container_extension, + custom_sid: '', + direct_source: '', + }, + }; +} + +export function buildMarketingSeriesInfo( + series: RawSeriesItem, + seasonCount: number, + episodesPerSeason: number +): RawSeriesInfo { + const seriesFixture = + SERIES.find((item) => item.name === series.name) ?? SERIES[0]; + const seasons = Array.from({ length: seasonCount }, (_, seasonIndex) => { + const seasonNumber = seasonIndex + 1; + return { + air_date: `${seriesFixture.year + seasonIndex}-09-01`, + episode_count: episodesPerSeason, + id: series.series_id * 100 + seasonNumber, + name: `Season ${seasonNumber}`, + overview: `Season ${seasonNumber} expands ${seriesFixture.name} with new heroic alliances, brighter emblems, and city-scale rescues.`, + season_number: seasonNumber, + cover: marketingAssetUrl( + 'season', + `${seriesFixture.name} Season ${seasonNumber}`, + '300x450' + ), + cover_big: marketingAssetUrl( + 'season', + `${seriesFixture.name} Season ${seasonNumber}`, + '500x750' + ), + }; + }); + const episodes: RawSeriesInfo['episodes'] = {}; + + seasons.forEach((season) => { + episodes[String(season.season_number)] = Array.from( + { length: episodesPerSeason }, + (_, episodeIndex) => { + const episodeNumber = episodeIndex + 1; + const episodeId = + MARKETING_EPISODE_ID_BASE + + (series.series_id - MARKETING_SERIES_ID_BASE) * 100 + + season.season_number * 10 + + episodeNumber; + return { + id: String(episodeId), + episode_num: episodeNumber, + title: `${seriesFixture.name} S${season.season_number}E${episodeNumber}`, + container_extension: 'mkv', + info: { + tmdb_id: episodeId, + releasedate: `${seriesFixture.year + season.season_number - 1}-10-${String( + episodeNumber + ).padStart(2, '0')}`, + plot: `A new emblem appears over the skyline while the team follows a rescue signal hidden in plain sight.`, + duration_secs: 2_640, + duration: '44min', + movie_image: marketingAssetUrl( + 'episode', + `${seriesFixture.name} Episode ${episodeNumber}`, + '600x338' + ), + bitrate: 4_400, + rating: Number( + Math.min( + 9.2, + seriesFixture.rating + episodeNumber / 20 + ).toFixed(1) + ), + }, + custom_sid: '', + added: String(ADDED_BASE - episodeId), + season: season.season_number, + direct_source: '', + }; + } + ); + }); + + return { + seasons, + episodes, + info: { + name: seriesFixture.name, + cover: series.cover, + plot: `${seriesFixture.tagline} ${seriesFixture.description}`, + cast: seriesFixture.actors, + director: seriesFixture.director, + genre: seriesFixture.genre, + releaseDate: `${seriesFixture.year}-09-01`, + last_modified: '2026-04-20', + rating: seriesFixture.rating.toFixed(1), + rating_5based: Number((seriesFixture.rating / 2).toFixed(1)), + backdrop_path: [ + marketingAssetUrl('backdrop', seriesFixture.name, '1280x720'), + ], + youtube_trailer: '', + episode_run_time: '44', + category_id: String(series.category_id), + }, + }; +} + +export function marketingAssetUrl( + kind: MarketingAssetKind, + title: string, + size: string +): string { + return `${marketingAssetOrigin()}/assets/marketing/${kind}/${slugify( + title + )}.svg?size=${encodeURIComponent(size)}`; +} + +export function renderMarketingAssetSvg( + kind: MarketingAssetKind, + slug: string, + size: string | undefined +): string { + const { width, height } = parseSize(size, kind); + const title = titleFromSlug(slug); + const palette = paletteFor(`${kind}:${slug}`); + const initials = title + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join(''); + + if (kind === 'logo') { + return svgDocument( + width, + height, + ` + + + + + + + + + + ${escapeSvg(initials)} + ` + ); + } + + if (kind === 'backdrop' || kind === 'episode') { + const skylineY = height * 0.7; + const heroX = width * (0.34 + (hash(slug) % 20) / 100); + const heroY = height * 0.38; + const accentX = width * (0.68 + (hash(`${slug}:light`) % 16) / 100); + + return svgDocument( + width, + height, + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + ); + } + + const titleFontSize = height * 0.07; + const subLabel = + kind === 'season' ? 'SEASON HERO ART' : 'FICTIONAL HERO FEATURE'; + const creditLine = creditLineFor(`${kind}:${slug}`); + const cityY = height * 0.74; + const titleLines = title + .split(/\s+/) + .reduce((lines, word) => { + const current = lines.at(-1) ?? ''; + if (`${current} ${word}`.trim().length > 13 && current) { + lines.push(word); + } else if (lines.length === 0) { + lines.push(word); + } else { + lines[lines.length - 1] = `${current} ${word}`.trim(); + } + return lines; + }, []) + .slice(0, 3); + + return svgDocument( + width, + height, + ` + + + + + + + + + + + + + + + + + + + + + + + ${subLabel} + ${titleLines + .map( + (line, index) => + `${escapeSvg(line.toUpperCase())}` + ) + .join('')} + ${escapeSvg(creditLine)} + IPTVnator fictional demo artwork + ` + ); +} + +function buildMarketingLiveStream( + channel: MarketingLiveChannel, + index: number +): RawLiveStream { + const streamId = MARKETING_LIVE_STREAM_ID_BASE + index; + return { + num: index + 1, + name: channel.name, + stream_type: 'live', + stream_id: streamId, + stream_icon: marketingAssetUrl('logo', channel.name, '256x256'), + epg_channel_id: `marketing-channel-${streamId}`, + added: String(ADDED_BASE - index * 4_000), + category_id: channel.categoryId, + custom_sid: '', + direct_source: '', + tv_archive: 1, + tv_archive_duration: 3, + rating_imdb: (7.2 + index / 10).toFixed(1), + }; +} + +function buildMarketingVodStream( + movie: MarketingMovie, + index: number +): RawVodStream { + const streamId = MARKETING_VOD_STREAM_ID_BASE + index; + return { + num: index + 1, + name: movie.name, + stream_type: 'movie', + stream_id: streamId, + stream_icon: marketingAssetUrl('poster', movie.name, '300x450'), + added: String(ADDED_BASE - index * 8_000), + category_id: movie.categoryId, + custom_sid: '', + direct_source: '', + rating: movie.rating, + rating_5based: Number((movie.rating / 2).toFixed(1)), + rating_imdb: movie.rating.toFixed(1), + container_extension: 'mkv', + type: 'movie', + }; +} + +function buildMarketingSeriesItem( + series: MarketingSeries, + index: number +): RawSeriesItem { + const seriesId = MARKETING_SERIES_ID_BASE + index; + return { + num: index + 1, + name: series.name, + series_id: seriesId, + cover: marketingAssetUrl('poster', series.name, '300x450'), + plot: `${series.tagline} ${series.description}`, + cast: series.actors, + director: series.director, + genre: series.genre, + releaseDate: `${series.year}-09-01`, + last_modified: '2026-04-20', + rating: series.rating.toFixed(1), + rating_5based: Number((series.rating / 2).toFixed(1)), + backdrop_path: [marketingAssetUrl('backdrop', series.name, '1280x720')], + youtube_trailer: '', + episode_run_time: '44', + category_id: Number(series.categoryId), + }; +} + +function buildMarketingEpgListings( + stream: RawLiveStream, + titles: string[] +): RawEpgListing[] { + const now = Math.floor(Date.now() / 1000); + const slotSeconds = 30 * 60; + const roundedNow = now - (now % slotSeconds); + const channelId = stream.epg_channel_id; + + return Array.from({ length: 8 }, (_, index) => { + const title = titles[index % titles.length]; + const startTimestamp = roundedNow + (index - 2) * slotSeconds; + return buildEpgListing({ + id: `${stream.stream_id}-${index}`, + epgId: channelId, + title, + description: `${title} on ${stream.name}, part of the fictional IPTVnator demo schedule.`, + startTimestamp, + stopTimestamp: startTimestamp + slotSeconds, + channelId, + }); + }); +} + +function marketingAssetOrigin(): string { + const port = process.env['PORT'] ?? '3211'; + return `http://localhost:${port}`; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function titleFromSlug(slug: string): string { + return slug + .replace(/\.svg$/i, '') + .split('-') + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(' '); +} + +function parseSize( + size: string | undefined, + kind: MarketingAssetKind +): { width: number; height: number } { + const fallback = + kind === 'backdrop' + ? { width: 1280, height: 720 } + : kind === 'episode' + ? { width: 600, height: 338 } + : kind === 'logo' + ? { width: 256, height: 256 } + : { width: 300, height: 450 }; + const match = size?.match(/^(\d+)x(\d+)$/); + if (!match) { + return fallback; + } + return { + width: Number(match[1]), + height: Number(match[2]), + }; +} + +function paletteFor(seed: string): [string, string, string] { + const palettes: Array<[string, string, string]> = [ + ['#0b1026', '#1b6b77', '#f2a65a'], + ['#15111f', '#6b3fa0', '#20c7b5'], + ['#071b2c', '#2457a6', '#f05d5e'], + ['#1b1b24', '#8d4f2a', '#e9c46a'], + ['#10251d', '#2a9d8f', '#e76f51'], + ['#18151f', '#b23a48', '#f4a261'], + ]; + const index = hash(seed) % palettes.length; + return palettes[index]; +} + +function creditLineFor(seed: string): string { + const credits = [ + 'STARRING MARA SOL / THEO QUILL / IRIS VALE', + 'STARRING LENA VOSS / MATEO RINN / CORA VALE', + 'STARRING KAI MERCER / NOVA BELL / REN SATO', + 'STARRING ANIKA FERN / MILO GREY / PAX ARDEN', + 'STARRING NICO REED / TESSA MOON / BRAM COLE', + 'STARRING ELLE FENN / RAFI NORTH / MIRA STONE', + ]; + return credits[hash(seed) % credits.length]; +} + +function hash(value: string): number { + return value.split('').reduce((acc, char) => { + return (acc * 31 + char.charCodeAt(0)) >>> 0; + }, 0); +} + +function svgDocument(width: number, height: number, body: string): string { + return `${body}`; +} + +function escapeSvg(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 3ad3b9207..94c683108 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -15,6 +15,8 @@ export interface ScenarioConfig { epgFixture?: 'timezone-focus'; /** Optional deterministic VOD detail profile for metadata fallback tests. */ vodDetailsFixture?: 'empty-metadata'; + /** Optional fictional release-marketing dataset with local demo artwork. */ + marketingFixture?: true; } /** @@ -105,6 +107,19 @@ export const SCENARIOS: Record = { expiryDate: '2099-12-31', vodDetailsFixture: 'empty-metadata', }, + 'marketing:marketing': { + name: 'marketing-demo', + description: + 'Fictional catalog for release screenshots — local generated artwork, no real media covers', + seed: 8020, + categoryCount: { live: 4, vod: 4, series: 4 }, + itemsPerCategory: 6, + seasonsPerSeries: 3, + episodesPerSeason: 6, + accountStatus: 'Active', + expiryDate: '2099-12-31', + marketingFixture: true, + }, 'expired:expired': { name: 'expired', description: 'Expired account — tests subscription expiry UI flow', diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 53fd0fb53..34d8b8e18 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -1,11 +1,18 @@ import http from 'http'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; import express from 'express'; import cors from 'cors'; import { dispatchAction } from './app/routes/dispatch.js'; import { resetAll } from './app/data-store.js'; +import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js'; const app = express(); const PORT = parseInt(process.env['PORT'] ?? '3211', 10); +const marketingRasterAssetRoot = join( + process.cwd(), + 'apps/xtream-mock-server/public/marketing' +); app.use(cors()); app.use(express.json()); @@ -21,6 +28,40 @@ app.post('/reset', (_req, res) => { res.json({ status: 'reset' }); }); +// ─── Local fictional artwork for release screenshots ────────────────────────── +app.get('/assets/marketing/:kind/:slug', (req, res) => { + const kind = req.params['kind'] as + | 'backdrop' + | 'episode' + | 'logo' + | 'poster' + | 'season'; + const slug = req.params['slug'] ?? ''; + const size = + typeof req.query['size'] === 'string' ? req.query['size'] : undefined; + + if (!['backdrop', 'episode', 'logo', 'poster', 'season'].includes(kind)) { + res.status(404).send('Unknown marketing asset kind'); + return; + } + + const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); + const rasterPath = join(marketingRasterAssetRoot, kind, `${rasterSlug}.png`); + + if (existsSync(rasterPath)) { + res + .type('image/png') + .set('Cache-Control', 'public, max-age=3600') + .send(readFileSync(rasterPath)); + return; + } + + res + .type('image/svg+xml') + .set('Cache-Control', 'public, max-age=3600') + .send(renderMarketingAssetSvg(kind, slug, size)); +}); + // ─── Direct Xtream player_api.php endpoint ───────────────────────────────────── app.get('/player_api.php', dispatchAction); diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 003e46ee4..7e52683b2 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -44,6 +44,8 @@ Re-requesting with the same credentials returns the exact same data until ``` apps/xtream-mock-server/ ├── project.json ← Nx targets: serve (port 3211), serve-with-watch +├── public/ +│ └── marketing/ ← committed fictional release artwork PNGs ├── tsconfig.json └── src/ ├── main.ts ← Express app bootstrap, all routes wired up @@ -53,6 +55,7 @@ apps/xtream-mock-server/ ├── generators/ │ ├── categories.generator.ts │ ├── live.generator.ts ← Live streams + EPG listings + │ ├── marketing.generator.ts ← Fictional release screenshot fixture │ ├── vod.generator.ts ← VOD streams + VodDetails │ └── series.generator.ts ← Series items + SeriesInfo ├── handlers/ @@ -82,9 +85,11 @@ Response: raw JSON (no envelope). Matches the real Xtream Codes API format. ### PWA proxy endpoint IPTVnator's PWA routes Xtream calls through: + ``` GET /xtream?url=&action=&username=&password=

``` + Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same @@ -109,16 +114,22 @@ All redirect to a publicly available HLS test stream ```json { - "user_info": { - "username": "user1", "password": "pass1", - "status": "active", "exp_date": "4102444799", - "is_trial": "0", "active_cons": "1", "max_connections": "2", - "allowed_output_formats": ["m3u8", "ts", "rtmp"] - }, - "server_info": { - "url": "http://localhost:3211", "port": "3211", - "timezone": "UTC", "timestamp_now": 1234567890 - } + "user_info": { + "username": "user1", + "password": "pass1", + "status": "active", + "exp_date": "4102444799", + "is_trial": "0", + "active_cons": "1", + "max_connections": "2", + "allowed_output_formats": ["m3u8", "ts", "rtmp"] + }, + "server_info": { + "url": "http://localhost:3211", + "port": "3211", + "timezone": "UTC", + "timestamp_now": 1234567890 + } } ``` @@ -135,11 +146,15 @@ All redirect to a publicly available HLS test stream ```json { - "num": 1, "name": "Acme Corp TV", - "stream_type": "live", "stream_id": 10000, - "stream_icon": "https://picsum.photos/seed/live-10000/100/100", - "epg_channel_id": "channel-10000.mock", - "category_id": "101", "tv_archive": 0, "tv_archive_duration": 0 + "num": 1, + "name": "Acme Corp TV", + "stream_type": "live", + "stream_id": 10000, + "stream_icon": "https://picsum.photos/seed/live-10000/100/100", + "epg_channel_id": "channel-10000.mock", + "category_id": "101", + "tv_archive": 0, + "tv_archive_duration": 0 } ``` @@ -147,15 +162,18 @@ All redirect to a publicly available HLS test stream ```json { - "epg_listings": [ - { - "id": "1000000", "epg_id": "channel-10000.mock", - "title": "base64encodedTitle", - "description": "base64encodedDescription", - "start": "2024-01-01 12:00:00", "end": "2024-01-01 12:30:00", - "start_timestamp": "1704110400", "stop_timestamp": "1704112200" - } - ] + "epg_listings": [ + { + "id": "1000000", + "epg_id": "channel-10000.mock", + "title": "base64encodedTitle", + "description": "base64encodedDescription", + "start": "2024-01-01 12:00:00", + "end": "2024-01-01 12:30:00", + "start_timestamp": "1704110400", + "stop_timestamp": "1704112200" + } + ] } ``` @@ -167,19 +185,19 @@ Both actions return the same full per-channel schedule shape: ```json { - "epg_listings": [ - { - "id": "10000-current", - "epg_id": "channel-10000.mock", - "title": "base64encodedTitle", - "description": "base64encodedDescription", - "start": "2026-04-05 04:30:00", - "end": "2026-04-05 05:00:00", - "start_timestamp": "1775363400", - "stop_timestamp": "1775365200", - "channel_id": "channel-10000.mock" - } - ] + "epg_listings": [ + { + "id": "10000-current", + "epg_id": "channel-10000.mock", + "title": "base64encodedTitle", + "description": "base64encodedDescription", + "start": "2026-04-05 04:30:00", + "end": "2026-04-05 05:00:00", + "start_timestamp": "1775363400", + "stop_timestamp": "1775365200", + "channel_id": "channel-10000.mock" + } + ] } ``` @@ -214,16 +232,19 @@ sometimes only respond to that misspelled action. ## Scenarios -| Key (`username:password`) | Seed | Categories | Items/cat | Account status | -|---------------------------|------|------------|-----------|----------------| -| `user1:pass1` | 1001 | 8 each | 40 | active | -| `large:large` | 9999 | 20 each | 200 | active | -| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | -| `minimal:minimal` | 3003 | 2 each | 5 | active | -| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | -| `expired:expired` | 4004 | 4 each | 10 | Expired | -| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | -| `` | hash | 6 each | 30 | active | +| Key (`username:password`) | Seed | Categories | Items/cat | Account status | +| ------------------------- | ---- | ------------------------ | --------- | -------------- | +| `user1:pass1` | 1001 | 8 each | 40 | active | +| `large:large` | 9999 | 20 each | 200 | active | +| `stress:stress` | 7777 | 16 each | 120 | active | +| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | +| `minimal:minimal` | 3003 | 2 each | 5 | active | +| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | +| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active | +| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active | +| `expired:expired` | 4004 | 4 each | 10 | Expired | +| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | +| `` | hash | 6 each | 30 | active | ### `epg:epg` fixture details @@ -238,6 +259,26 @@ That deliberate mismatch lets Electron tests verify the renderer uses timestamp fields for sorting, current-program selection, progress bars, and local clock labels instead of trusting provider-local strings. +### `marketing:marketing` fixture details + +This scenario is reserved for release screenshots and marketing materials: + +- VOD and series data use 30 curated fictional titles instead of faker-generated + titles or real media metadata +- posters and backdrops are served from committed PNG files in + `apps/xtream-mock-server/public/marketing/{poster,backdrop}/` +- `tools/release/generate-marketing-artwork.ts` generates the PNGs with + `gpt-image-2` only when `OPENAI_API_KEY` is present; it also writes the + prompt/asset manifest at `apps/xtream-mock-server/public/marketing/manifest.json` +- the manifest assigns distinct genre and visual-medium profiles per title, + including documentary, noir, animated family, retro rescue drama, cyberpunk, + anime-inspired space school, and workplace dramedy styles +- screenshot capture remains deterministic and offline because + `tools/release/capture-v020-screenshots.ts` consumes the local mock server + assets and never calls OpenAI +- the SVG renderer in `marketing.generator.ts` remains the fallback for missing + assets, live logos, season covers, and episode thumbnails + --- ## Playwright Integration @@ -275,5 +316,8 @@ await page.route('**/localhost:3000/xtream**', async (route) => { - **Add new actions**: Implement a handler function and add a `case` in `routes/dispatch.ts` - **Add new scenarios**: Add an entry to `SCENARIOS` in `scenarios.ts` - **Add deterministic EPG fixtures**: Extend `ScenarioConfig.epgFixture` and populate `epgListingsByStreamId` in `data-store.ts` +- **Refresh release artwork**: Run `pnpm release:artwork:dry-run`, then + `OPENAI_API_KEY=... pnpm release:artwork:generate`, inspect the generated PNGs, + and finish with `pnpm release:artwork:validate` - **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario - **Custom stream URLs**: Edit the HLS stub redirect in `main.ts` diff --git a/tools/release/capture-v020-screenshots.ts b/tools/release/capture-v020-screenshots.ts new file mode 100644 index 000000000..eb09fce16 --- /dev/null +++ b/tools/release/capture-v020-screenshots.ts @@ -0,0 +1,1165 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { + accessSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from '@playwright/test'; + +type ThemeName = 'dark' | 'light'; +type ScreenshotSlug = + | 'dashboard' + | 'm3u-live-groups-two-column' + | 'settings' + | 'xtream-series-season-open' + | 'xtream-vod-details'; + +type CaptureTarget = { + slug: ScreenshotSlug; + title: string; + kicker: string; +}; + +const workspaceRoot = process.cwd(); +const electronMainPath = path.join( + workspaceRoot, + 'dist/apps/electron-backend/main.js' +); +const rendererIndexPath = path.join(workspaceRoot, 'dist/apps/web/index.html'); +const xtreamMockOrigin = 'http://localhost:3211'; +const outputRoot = path.join( + workspaceRoot, + 'apps/website/public/blog/v0-20/screenshots' +); +const heroOutputPath = path.join( + workspaceRoot, + 'apps/website/public/blog/v0-20/v0-20-hero.png' +); +const viewport = { width: 1280, height: 720 }; +const captureTargets: CaptureTarget[] = [ + { + slug: 'dashboard', + title: 'Content-first dashboard', + kicker: 'New in v0.20', + }, + { + slug: 'xtream-vod-details', + title: 'Richer movie details', + kicker: 'Xtream', + }, + { + slug: 'xtream-series-season-open', + title: 'Series seasons and episodes', + kicker: 'Xtream', + }, + { + slug: 'settings', + title: 'Reworked settings', + kicker: 'Customization', + }, + { + slug: 'm3u-live-groups-two-column', + title: 'Two-column live TV', + kicker: 'M3U', + }, +]; +let capturedM3uPlaylistId: string | undefined; +let capturedXtreamPlaylistId: string | undefined; + +async function main(): Promise { + assertBuiltRuntimeExists(); + + mkdirSync(outputRoot, { recursive: true }); + const managedMockServer = await ensureXtreamMockServer(); + const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-v020-shots-')); + const m3uFixturePath = writeM3uPlaylistFixture(dataDir); + let app: ElectronApplication | undefined; + + try { + app = await launchApp(dataDir); + const page = await findMainWindow(app); + await sizeElectronWindow(app); + await waitForAppReady(page); + await seedDemoData(page, m3uFixturePath); + + for (const theme of ['dark', 'light'] as ThemeName[]) { + await applyTheme(page, theme); + await captureThemeSet(page, theme); + } + + await createHeroImage(page); + await verifyExpectedAssets(); + } finally { + await app?.close().catch(() => undefined); + managedMockServer?.kill('SIGTERM'); + rmSync(dataDir, { recursive: true, force: true }); + } +} + +function assertBuiltRuntimeExists(): void { + if (!fileExists(electronMainPath) || !fileExists(rendererIndexPath)) { + throw new Error( + [ + 'Electron E2E runtime is missing.', + `Expected ${electronMainPath}`, + `Expected ${rendererIndexPath}`, + 'Build it first, for example: pnpm nx run electron-backend:build-e2e', + ].join('\n') + ); + } +} + +async function ensureXtreamMockServer(): Promise { + if (await isHealthy(`${xtreamMockOrigin}/health`)) { + return undefined; + } + + const child = spawn( + path.join(workspaceRoot, 'node_modules/.bin/tsx'), + ['apps/xtream-mock-server/src/main.ts'], + { + cwd: workspaceRoot, + env: { + ...process.env, + NODE_ENV: 'development', + PORT: '3211', + }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + child.stdout?.on('data', (chunk) => { + process.stdout.write(`[xtream-mock] ${chunk}`); + }); + child.stderr?.on('data', (chunk) => { + process.stderr.write(`[xtream-mock] ${chunk}`); + }); + + await waitForHealthy(`${xtreamMockOrigin}/health`, 20_000); + return child; +} + +function buildM3uPlaylist(): string { + const channels = [ + ['Newsroom', 'Aurora Local', 'aurora-local'], + ['Newsroom', 'Civic Pulse', 'civic-pulse'], + ['Sports', 'Fieldside One', 'fieldside-one'], + ['Sports', 'Motion Arena', 'motion-arena'], + ['Kids', 'Horizon Kids', 'horizon-kids'], + ['Kids', 'Story Lantern', 'story-lantern'], + ['Culture', 'Atlas Culture', 'atlas-culture'], + ['Culture', 'Night Music', 'night-music'], + ]; + const stream = `${xtreamMockOrigin}/live/marketing/marketing/52000.m3u8`; + const lines = ['#EXTM3U']; + + channels.forEach(([group, title, slug], index) => { + lines.push( + `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${xtreamMockOrigin}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}` + ); + lines.push(stream); + }); + + return `${lines.join('\n')}\n`; +} + +function writeM3uPlaylistFixture(dataDir: string): string { + const filePath = path.join(dataDir, 'v020-demo.m3u'); + writeFileSync(filePath, buildM3uPlaylist(), 'utf8'); + return filePath; +} + +function marketingAssetUrl( + kind: 'backdrop' | 'episode' | 'logo' | 'poster' | 'season', + title: string, + size: string +): string { + return `${xtreamMockOrigin}/assets/marketing/${kind}/${slugifyForFile( + title + )}.svg?size=${encodeURIComponent(size)}`; +} + +async function launchApp(dataDir: string): Promise { + return electron.launch({ + args: [electronMainPath], + env: { + ...process.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + }); +} + +async function findMainWindow(app: ElectronApplication): Promise { + await new Promise((resolve) => setTimeout(resolve, 1500)); + + for (const candidate of app.windows()) { + const title = await candidate.title(); + if (!title.includes('DevTools')) { + return candidate; + } + } + + return app.firstWindow(); +} + +async function sizeElectronWindow(app: ElectronApplication): Promise { + await app.evaluate(({ BrowserWindow }, size) => { + const win = BrowserWindow.getAllWindows().find( + (candidate) => !candidate.webContents.getTitle().includes('DevTools') + ); + win?.setSize(size.width, size.height); + win?.center(); + }, viewport); +} + +async function waitForAppReady(page: Page): Promise { + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('app-root', { timeout: 45_000 }); + await page.waitForFunction( + () => (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > 0, + { timeout: 45_000 } + ); +} + +async function seedDemoData(page: Page, m3uPath: string): Promise { + await addXtreamPortal(page); + await addM3uPlaylist(page, m3uPath); + await seedDashboardActivity(page); +} + +async function addXtreamPortal(page: Page): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + await clickDialogOption(dialog, /^xtream$/i); + await dialog.locator('#title').fill('v0.20 Fictional Xtream'); + await dialog.locator('#serverUrl').fill(xtreamMockOrigin); + await dialog.locator('#username').fill('marketing'); + await dialog.locator('#password').fill('marketing'); + await dialog.getByRole('button', { name: /^(add|add playlist)$/i }).last().click(); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { + timeout: 45_000, + }); + capturedXtreamPlaylistId = playlistIdFromUrl(page.url(), 'xtreams'); + await page.locator('.category-content-layout, app-content-card').first().waitFor({ + state: 'visible', + timeout: 45_000, + }); +} + +async function addM3uPlaylist(page: Page, m3uPath: string): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + await clickDialogOption(dialog, /^m3u$/i); + await clickDialogOption(dialog, /add\s+via\s+file\s+upload/i); + + const fileInput = dialog.locator('input[type="file"][name="playlist"]'); + await fileInput.evaluate((element, selectedFilePath) => { + (element as HTMLInputElement).dataset.filePathOverride = + selectedFilePath; + }, m3uPath); + await fileInput.setInputFiles(m3uPath); + await dialog + .getByRole('button', { name: /add playlist/i }) + .last() + .click({ timeout: 15_000 }); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { + timeout: 45_000, + }); + capturedM3uPlaylistId = playlistIdFromUrl(page.url(), 'playlists'); + try { + await page.locator('[data-test-id="channel-item"]').first().waitFor({ + state: 'visible', + timeout: 60_000, + }); + } catch (error) { + await writeDebugScreenshot(page, 'm3u-import'); + const diagnostics = await page.evaluate(() => ({ + bodyText: document.body.innerText.slice(0, 2000), + title: document.title, + url: location.href, + })); + + console.error( + `[capture] M3U import did not show channels: ${JSON.stringify( + diagnostics, + null, + 2 + )}` + ); + throw error; + } +} + +async function seedDashboardActivity(page: Page): Promise { + const playlistId = requireCapturedPlaylistId('xtreams'); + const items = [ + { + xtreamId: 62000, + type: 'movie' as const, + backdropUrl: marketingAssetUrl('backdrop', 'Crimson Skylark', '1280x720'), + favorite: true, + recent: true, + }, + { + xtreamId: 62001, + type: 'movie' as const, + backdropUrl: marketingAssetUrl('backdrop', 'The Voltage Guard', '1280x720'), + favorite: true, + recent: true, + }, + { + xtreamId: 62002, + type: 'movie' as const, + backdropUrl: marketingAssetUrl('backdrop', 'Midnight Mantle', '1280x720'), + favorite: true, + recent: false, + }, + { + xtreamId: 72000, + type: 'series' as const, + backdropUrl: marketingAssetUrl('backdrop', 'Skyline Sentinels', '1280x720'), + favorite: true, + recent: true, + }, + { + xtreamId: 72001, + type: 'series' as const, + backdropUrl: marketingAssetUrl('backdrop', 'The Aegis Club', '1280x720'), + favorite: true, + recent: false, + }, + ]; + + await page.evaluate( + async ({ items, playlistId }) => { + const electronApi = (window as typeof window & { + electron?: { + dbAddFavorite?: ( + contentId: number, + playlistId: string, + backdropUrl?: string + ) => Promise; + dbAddRecentItem?: ( + contentId: number, + playlistId: string, + backdropUrl?: string + ) => Promise; + dbGetContentByXtreamId?: ( + xtreamId: number, + playlistId: string, + contentType?: 'live' | 'movie' | 'series' + ) => Promise<{ id: number } | null>; + }; + }).electron; + + if (!electronApi?.dbGetContentByXtreamId) { + throw new Error('Electron database bridge is unavailable.'); + } + + for (const item of items) { + const content = await electronApi.dbGetContentByXtreamId( + item.xtreamId, + playlistId, + item.type + ); + + if (!content?.id) { + throw new Error( + `Could not find imported Xtream content ${item.xtreamId}.` + ); + } + + if (item.favorite) { + await electronApi.dbAddFavorite?.( + content.id, + playlistId, + item.backdropUrl + ); + } + + if (item.recent) { + await electronApi.dbAddRecentItem?.( + content.id, + playlistId, + item.backdropUrl + ); + } + } + }, + { items, playlistId } + ); +} + +async function openAddPlaylistDialog(page: Page): Promise { + await page.getByRole('button', { name: /add playlist/i }).first().click(); + await page.locator('mat-dialog-container').last().waitFor({ + state: 'visible', + timeout: 15_000, + }); +} + +async function clickDialogOption(dialog: ReturnType, label: RegExp): Promise { + const tab = dialog.getByRole('tab', { name: label }).first(); + if ((await tab.count()) > 0) { + await tab.click(); + return; + } + + await dialog.getByRole('button', { name: label }).first().click(); +} + +async function applyTheme(page: Page, theme: ThemeName): Promise { + await openSettings(page); + const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; + const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); + + await themeButton.scrollIntoViewIfNeeded(); + await themeButton.click(); + + const saveButton = page.locator('[data-test-id="save-settings"]').first(); + if (await saveButton.isEnabled()) { + await saveButton.click(); + await waitForTransientUiToSettle(page); + } + + await page.waitForFunction( + (expectedTheme) => + document.body.classList.contains('dark-theme') === + (expectedTheme === 'dark'), + theme, + { timeout: 10_000 } + ); +} + +async function openSettings(page: Page): Promise { + await page.locator('a[href$="/workspace/settings"]').first().click(); + await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); + await page.locator('[data-test-id="settings-container"]').waitFor({ + state: 'visible', + timeout: 15_000, + }); +} + +async function captureThemeSet(page: Page, theme: ThemeName): Promise { + await captureCurrentView(page, theme, 'settings'); + await openDashboard(page); + await captureCurrentView(page, theme, 'dashboard'); + await openXtreamVodDetails(page); + await captureCurrentView(page, theme, 'xtream-vod-details'); + await openXtreamSeriesSeason(page); + await captureCurrentView(page, theme, 'xtream-series-season-open'); + await openM3uGroupsView(page); + await captureCurrentView(page, theme, 'm3u-live-groups-two-column'); +} + +async function openDashboard(page: Page): Promise { + await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await page.locator('[data-test-id="dashboard-page"]').waitFor({ + state: 'visible', + timeout: 20_000, + }); + await waitForTransientUiToSettle(page); + await page.locator('[data-test-id="dashboard-hero"]').waitFor({ + state: 'visible', + timeout: 30_000, + }); + + for (const testId of [ + 'dashboard-recently-watched-rail', + 'dashboard-global-favorites-rail', + 'dashboard-recent-sources-rail', + 'dashboard-xtream-recently-added-rail', + ]) { + await page.locator(`[data-test-id="${testId}"]`).waitFor({ + state: 'visible', + timeout: 30_000, + }); + } +} + +async function waitForTransientUiToSettle(page: Page): Promise { + await page + .locator('.mat-mdc-snack-bar-container') + .first() + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => undefined); + await page.evaluate(() => { + document + .querySelectorAll('.mat-mdc-snack-bar-container, simple-snack-bar') + .forEach((element) => { + const overlayPane = element.closest('.cdk-overlay-pane'); + (overlayPane ?? element).remove(); + }); + }); + await page.waitForTimeout(250); +} + +async function openXtreamVodDetails(page: Page): Promise { + const xtreamId = requireCapturedPlaylistId('xtreams'); + + await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/vod`); + await clickCategoryByName(page, 'Hero Premieres'); + await clickFirstGridCard(page); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, { + timeout: 30_000, + }); + await page.locator('app-content-hero').waitFor({ + state: 'visible', + timeout: 30_000, + }); + await page.waitForTimeout(700); +} + +async function openXtreamSeriesSeason(page: Page): Promise { + const xtreamId = requireCapturedPlaylistId('xtreams'); + + await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/series`); + await clickCategoryByName(page, 'Hero Teams'); + await clickFirstGridCard(page); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, { + timeout: 30_000, + }); + await page.locator('app-season-container').waitFor({ + state: 'visible', + timeout: 30_000, + }); + await page.locator('.season-card').first().click(); + await page.locator('.episode-card, .episode-list-item').first().waitFor({ + state: 'visible', + timeout: 20_000, + }); + await page.waitForTimeout(700); +} + +async function openM3uGroupsView(page: Page): Promise { + const playlistId = requireCapturedPlaylistId('playlists'); + + await switchPlaylistFromHeader(page, 'v020-demo'); + await page.waitForURL(new RegExp(`/workspace/playlists/${playlistId}/`), { + timeout: 20_000, + }); + await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`); + await page.waitForURL(/\/workspace\/playlists\/[^/]+\/groups/, { + timeout: 20_000, + }); + await page.locator('.group-nav-item').first().waitFor({ + state: 'visible', + timeout: 20_000, + }); + await page.locator('.group-nav-item').first().click(); + await page.locator('[data-test-id="channel-item"]').first().click(); + await page.waitForTimeout(1_000); +} + +async function switchPlaylistFromHeader( + page: Page, + title: string +): Promise { + await page.locator('app-playlist-switcher .playlist-switcher-trigger').click(); + + const item = page + .locator('.cdk-overlay-pane .playlist-item') + .filter({ hasText: title }) + .first(); + + await item.waitFor({ state: 'visible', timeout: 20_000 }); + await item.click(); + await page.locator('.cdk-overlay-pane .playlist-item').first().waitFor({ + state: 'detached', + timeout: 10_000, + }); +} + +async function clickCategoryByName( + page: Page, + categoryName: string +): Promise { + const category = page + .locator('app-workspace-context-panel .category-item') + .filter({ hasText: categoryName }) + .first(); + + try { + await category.waitFor({ state: 'visible', timeout: 30_000 }); + } catch (error) { + await writeDebugScreenshot(page, `category-${slugifyForFile(categoryName)}`); + const diagnostics = await page.evaluate(() => ({ + bodyText: document.body.innerText.slice(0, 2500), + categories: Array.from( + document.querySelectorAll( + 'app-workspace-context-panel .category-item' + ) + ).map((element) => ({ + id: element.getAttribute('data-category-id'), + text: (element.textContent ?? '').trim(), + })), + title: document.title, + url: location.href, + })); + + console.error( + `[capture] Category ${categoryName} was not visible: ${JSON.stringify( + diagnostics, + null, + 2 + )}` + ); + throw error; + } + await category.click(); + await page.waitForTimeout(600); +} + +async function clickFirstGridCard(page: Page): Promise { + const card = page.locator('.category-content-layout mat-card').first(); + + await card.waitFor({ state: 'visible', timeout: 30_000 }); + await card.click(); +} + +function requireCapturedPlaylistId( + provider: 'playlists' | 'xtreams' +): string { + const id = + provider === 'playlists' + ? capturedM3uPlaylistId + : capturedXtreamPlaylistId; + + if (!id) { + throw new Error(`Could not resolve captured ${provider} playlist id.`); + } + + return id; +} + +function playlistIdFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { + const match = url.match(new RegExp(`/workspace/${provider}/([^/]+)`)); + + if (!match?.[1]) { + throw new Error(`Could not resolve ${provider} playlist id from URL ${url}`); + } + + return match[1]; +} + +async function clickHrefSuffix(page: Page, suffix: string): Promise { + const link = page.locator(`a[href$="${suffix}"]`).first(); + + await link.waitFor({ state: 'attached', timeout: 20_000 }); + await link.click({ force: true }); + await page.waitForTimeout(500); +} + +async function captureCurrentView( + page: Page, + theme: ThemeName, + slug: ScreenshotSlug +): Promise { + const originalPath = screenshotPath('original', theme, slug); + + mkdirSync(path.dirname(originalPath), { recursive: true }); + await prepareForScreenshot(page); + await page.screenshot({ + path: originalPath, + type: 'png', + fullPage: slug === 'dashboard', + scale: 'css', + }); + + await createDesignedCopy( + page, + originalPath, + screenshotPath('designed', theme, slug), + { + ...captureTargets.find((target) => target.slug === slug)!, + theme, + } + ); +} + +async function prepareForScreenshot(page: Page): Promise { + await page.mouse.move(viewport.width - 32, viewport.height - 32); + await page.keyboard.press('Escape').catch(() => undefined); + await waitForTransientUiToSettle(page); + await page + .locator('.mat-mdc-tooltip, .mdc-tooltip, .mat-tooltip-panel') + .first() + .waitFor({ state: 'detached', timeout: 1_500 }) + .catch(() => undefined); + await page.waitForTimeout(200); +} + +async function writeDebugScreenshot(page: Page, name: string): Promise { + const debugPath = path.join(outputRoot, 'debug', `${name}.png`); + mkdirSync(path.dirname(debugPath), { recursive: true }); + await page.screenshot({ path: debugPath, fullPage: true, type: 'png' }); +} + +function screenshotPath( + variant: 'designed' | 'original', + theme: ThemeName, + slug: ScreenshotSlug +): string { + return path.join(outputRoot, variant, theme, `${slug}.png`); +} + +async function createDesignedCopy( + page: Page, + inputPath: string, + outputPath: string, + options: CaptureTarget & { theme: ThemeName } +): Promise { + mkdirSync(path.dirname(outputPath), { recursive: true }); + + const canvasWidth = 1920; + const canvasHeight = 1080; + const screenshotWidth = 1460; + const screenshotHeight = 822; + const left = 360; + const top = 170; + const palette = + options.theme === 'dark' + ? { + bg1: '#090b12', + bg2: '#172235', + accent: '#49c6e5', + text: '#f8fafc', + muted: '#a7b4c8', + } + : { + bg1: '#eef2f7', + bg2: '#dce7ef', + accent: '#2563eb', + text: '#111827', + muted: '#475569', + }; + const dataUrl = await page.evaluate( + async ({ + inputDataUrl, + options, + palette, + canvasWidth, + canvasHeight, + screenshotWidth, + screenshotHeight, + left, + top, + }) => { + const image = await loadCanvasImage(inputDataUrl); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Canvas 2D context is unavailable.'); + } + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const background = context.createLinearGradient( + 0, + 0, + canvasWidth, + canvasHeight + ); + background.addColorStop(0, palette.bg1); + background.addColorStop(1, palette.bg2); + context.fillStyle = background; + context.fillRect(0, 0, canvasWidth, canvasHeight); + + context.globalAlpha = 0.16; + context.fillStyle = palette.accent; + context.beginPath(); + context.arc(1710, 140, 260, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 0.12; + context.beginPath(); + context.arc(160, 930, 340, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 0.28; + context.strokeStyle = palette.accent; + context.lineWidth = 4; + context.beginPath(); + context.moveTo(0, 810); + context.bezierCurveTo(420, 650, 720, 1040, 1220, 840); + context.bezierCurveTo(1460, 744, 1720, 610, 1920, 720); + context.stroke(); + context.globalAlpha = 1; + + context.fillStyle = palette.accent; + context.font = '800 28px Arial, Helvetica, sans-serif'; + context.fillText(options.kicker.toUpperCase(), 110, 150); + context.fillStyle = palette.text; + context.font = '900 72px Arial, Helvetica, sans-serif'; + context.fillText(options.title, 110, 220); + context.fillStyle = palette.muted; + context.font = '600 28px Arial, Helvetica, sans-serif'; + context.fillText( + 'IPTVnator v0.20 release preview using fictional demo content', + 110, + 278 + ); + + context.save(); + context.shadowColor = + options.theme === 'dark' + ? 'rgba(0, 0, 0, 0.42)' + : 'rgba(0, 0, 0, 0.18)'; + context.shadowBlur = 76; + context.shadowOffsetY = 34; + context.globalAlpha = options.theme === 'dark' ? 0.78 : 0.9; + context.fillStyle = options.theme === 'dark' ? '#020617' : '#ffffff'; + drawRoundRect( + context, + left - 18, + top - 18, + screenshotWidth + 36, + screenshotHeight + 36, + 34 + ); + context.fill(); + context.restore(); + + context.save(); + drawRoundRect(context, left, top, screenshotWidth, screenshotHeight, 18); + context.clip(); + context.drawImage( + image, + 0, + 0, + image.naturalWidth, + image.naturalHeight, + left, + top, + screenshotWidth, + screenshotHeight + ); + context.restore(); + + context.strokeStyle = hexToRgba(palette.accent, 0.35); + context.lineWidth = 2; + drawRoundRect( + context, + left - 18, + top - 18, + screenshotWidth + 36, + screenshotHeight + 36, + 34 + ); + context.stroke(); + + return canvas.toDataURL('image/png'); + + function loadCanvasImage(src: string): Promise { + return new Promise((resolve, reject) => { + const candidate = new Image(); + candidate.onload = () => resolve(candidate); + candidate.onerror = () => + reject(new Error('Could not load screenshot image.')); + candidate.src = src; + }); + } + + function drawRoundRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number + ): void { + context.beginPath(); + context.moveTo(x + radius, y); + context.lineTo(x + width - radius, y); + context.quadraticCurveTo(x + width, y, x + width, y + radius); + context.lineTo(x + width, y + height - radius); + context.quadraticCurveTo( + x + width, + y + height, + x + width - radius, + y + height + ); + context.lineTo(x + radius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - radius); + context.lineTo(x, y + radius); + context.quadraticCurveTo(x, y, x + radius, y); + context.closePath(); + } + + function hexToRgba(hex: string, alpha: number): string { + const normalized = hex.replace('#', ''); + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + }, + { + inputDataUrl: fileToDataUrl(inputPath), + options, + palette, + canvasWidth, + canvasHeight, + screenshotWidth, + screenshotHeight, + left, + top, + } + ); + + writeDataUrlPng(outputPath, dataUrl); +} + +async function createHeroImage(page: Page): Promise { + const dashboard = screenshotPath('original', 'dark', 'dashboard'); + const vod = screenshotPath('original', 'dark', 'xtream-vod-details'); + const series = screenshotPath('original', 'light', 'xtream-series-season-open'); + mkdirSync(path.dirname(heroOutputPath), { recursive: true }); + + const dataUrl = await page.evaluate( + async ({ dashboard, vod, series }) => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Canvas 2D context is unavailable.'); + } + + canvas.width = 1600; + canvas.height = 900; + + const background = context.createLinearGradient(0, 0, 1600, 900); + background.addColorStop(0, '#06111f'); + background.addColorStop(0.52, '#111827'); + background.addColorStop(1, '#233b54'); + context.fillStyle = background; + context.fillRect(0, 0, 1600, 900); + + context.globalAlpha = 0.18; + context.fillStyle = '#49c6e5'; + context.beginPath(); + context.arc(1380, 120, 240, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 0.15; + context.fillStyle = '#f2a65a'; + context.beginPath(); + context.arc(170, 790, 290, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 1; + + context.fillStyle = '#49c6e5'; + context.font = '800 30px Arial, Helvetica, sans-serif'; + context.fillText('IPTVNATOR V0.20', 86, 138); + context.fillStyle = '#f8fafc'; + context.font = '900 78px Arial, Helvetica, sans-serif'; + context.fillText('Unified UI.', 86, 224); + context.fillText('Content-first.', 86, 306); + context.fillStyle = '#b9c4d6'; + context.font = '600 28px Arial, Helvetica, sans-serif'; + context.fillText( + 'Dashboard, search, favorites, history, and settings reworked across M3U, Xtream, and Stalker.', + 88, + 366 + ); + + await drawFramedImage(context, dashboard, 650, 130, 780, 438, 26, '#020617'); + await drawFramedImage(context, vod, 500, 505, 520, 292, 22, '#020617'); + await drawFramedImage(context, series, 990, 565, 430, 242, 20, '#f8fafc'); + + return canvas.toDataURL('image/png'); + + async function drawFramedImage( + context: CanvasRenderingContext2D, + src: string, + x: number, + y: number, + width: number, + height: number, + radius: number, + frameColor: string + ): Promise { + const image = await loadCanvasImage(src); + + context.save(); + context.shadowColor = 'rgba(0, 0, 0, 0.45)'; + context.shadowBlur = 60; + context.shadowOffsetY = 28; + context.globalAlpha = frameColor === '#f8fafc' ? 0.94 : 0.82; + context.fillStyle = frameColor; + drawRoundRect(context, x, y, width, height, radius); + context.fill(); + context.restore(); + + context.save(); + drawRoundRect(context, x, y, width, height, radius); + context.clip(); + context.drawImage( + image, + 0, + 0, + image.naturalWidth, + image.naturalHeight, + x, + y, + width, + height + ); + context.restore(); + } + + function loadCanvasImage(src: string): Promise { + return new Promise((resolve, reject) => { + const candidate = new Image(); + candidate.onload = () => resolve(candidate); + candidate.onerror = () => + reject(new Error('Could not load screenshot image.')); + candidate.src = src; + }); + } + + function drawRoundRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number + ): void { + context.beginPath(); + context.moveTo(x + radius, y); + context.lineTo(x + width - radius, y); + context.quadraticCurveTo(x + width, y, x + width, y + radius); + context.lineTo(x + width, y + height - radius); + context.quadraticCurveTo( + x + width, + y + height, + x + width - radius, + y + height + ); + context.lineTo(x + radius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - radius); + context.lineTo(x, y + radius); + context.quadraticCurveTo(x, y, x + radius, y); + context.closePath(); + } + }, + { + dashboard: fileToDataUrl(dashboard), + vod: fileToDataUrl(vod), + series: fileToDataUrl(series), + } + ); + + writeDataUrlPng(heroOutputPath, dataUrl); +} + +async function verifyExpectedAssets(): Promise { + const expected = [ + heroOutputPath, + ...(['dark', 'light'] as ThemeName[]).flatMap((theme) => + captureTargets.flatMap((target) => [ + screenshotPath('original', theme, target.slug), + screenshotPath('designed', theme, target.slug), + ]) + ), + ]; + + for (const filePath of expected) { + if (!fileExists(filePath)) { + throw new Error(`Expected screenshot asset was not created: ${filePath}`); + } + + const dimensions = readPngDimensions(filePath); + if (!dimensions.width || !dimensions.height) { + throw new Error(`Screenshot asset has invalid dimensions: ${filePath}`); + } + } +} + +async function isHealthy(url: string): Promise { + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +async function waitForHealthy(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await isHealthy(url)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error(`Timed out waiting for ${url}`); +} + +function fileExists(filePath: string): boolean { + try { + accessSync(filePath); + return true; + } catch { + return false; + } +} + +function fileToDataUrl(filePath: string): string { + return `data:image/png;base64,${readFileSync(filePath).toString('base64')}`; +} + +function writeDataUrlPng(filePath: string, dataUrl: string): void { + const marker = 'data:image/png;base64,'; + + if (!dataUrl.startsWith(marker)) { + throw new Error(`Expected PNG data URL for ${filePath}`); + } + + writeFileSync(filePath, Buffer.from(dataUrl.slice(marker.length), 'base64')); +} + +function readPngDimensions(filePath: string): { height: number; width: number } { + const buffer = readFileSync(filePath); + const pngSignature = '89504e470d0a1a0a'; + + if (buffer.subarray(0, 8).toString('hex') !== pngSignature) { + throw new Error(`Expected PNG signature in ${filePath}`); + } + + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} + +function slugifyForFile(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tools/release/generate-marketing-artwork.ts b/tools/release/generate-marketing-artwork.ts new file mode 100644 index 000000000..a46652354 --- /dev/null +++ b/tools/release/generate-marketing-artwork.ts @@ -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 { + 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 = { + '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 = { + '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 { + 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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +});