@clawhub-jonathanlindsay-6afd537043
Generate, edit, extend, and manage AI videos using OpenAI's Sora 2 API. Includes marketing-ready prompt templates for product demos, social ads, brand spots,...
---
name: sora-video
description: "Generate, edit, extend, and manage AI videos using OpenAI's Sora 2 API. Includes marketing-ready prompt templates for product demos, social ads, brand spots, and launch teasers. Requires customer-provided OPENAI_API_KEY."
version: 1.0.0
dependencies: []
tags:
- video
- ai-generation
- sora
- marketing
- content-creation
- openai
---
# sora-video
AI video generation skill for Stomme AI customers using OpenAI's Sora 2 API. Wraps a production-grade Python CLI with marketing-focused prompt templates for business use cases.
## Prerequisites
### OpenAI API Key (Required)
Customers need their own `OPENAI_API_KEY` from OpenAI's platform:
1. Go to [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
2. Create a new API key with video generation permissions
3. Set it as an environment variable: `export OPENAI_API_KEY="sk-..."`
4. Ensure your OpenAI organization has Sora API access enabled
> **Important:** A ChatGPT Pro/Plus subscription does NOT provide API access to Sora. You need a separate API key with pay-per-use billing from platform.openai.com.
### Python + uv
The CLI requires Python 3.10+ and uses `uv` for dependency management (auto-installs the `openai` SDK):
```bash
# Install uv if not present
curl -LsSf https://astral.sh/uv/install.sh | sh
```
## Pricing Guide
| Model | Duration | Approximate Cost |
|-------|----------|-----------------|
| sora-2 | 4s | ~$0.10 |
| sora-2 | 8s | ~$0.20 |
| sora-2 | 12-16s | ~$0.30 |
| sora-2 | 20s | ~$0.40 |
| sora-2-pro | 4s | ~$0.25 |
| sora-2-pro | 8s | ~$0.40 |
| sora-2-pro | 12-16s | ~$0.50 |
| sora-2-pro | 20s | ~$0.60 |
Costs are per video generation attempt. Failed or cancelled jobs are not billed. Prices are approximate and may change — check [OpenAI's pricing page](https://openai.com/api/pricing/) for current rates.
## When to Use
- Generate product demo videos from text descriptions
- Create social media ad clips (Instagram, TikTok, LinkedIn)
- Produce brand identity spots and launch teasers
- Edit or extend existing generated videos
- Create reusable non-human character references for brand mascots
- Batch-generate multiple video variants for A/B testing
## Decision Tree
- **Product demo** → use `templates/product-demo.md` template + `create`
- **Social ad** → use `templates/social-ads.md` template + `create` (4-8s)
- **Brand spot** → use `templates/brand-spots.md` template + `create` or `create-and-poll`
- **Launch teaser** → use `templates/launch-teaser.md` template + `create-and-poll`
- **Character-based shots** → `create-character` first, then `create` with character IDs
- **Edit existing video** → `edit` (one targeted change per iteration)
- **Extend existing video** → `extend` (continue timeline)
- **Batch variants** → `create-batch` with JSONL input
- **Check status** → `status` or `poll`
- **Download assets** → `download` (video/thumbnail/spritesheet)
## Workflow
1. Select a template from `templates/` matching the use case (or write a custom prompt).
2. Run the CLI via `scripts/sora.py` with appropriate flags.
3. For async jobs, poll until completion (or use `create-and-poll`).
4. Download assets before URLs expire (~1 hour).
5. Iterate with `edit` (targeted changes) or `extend` (timeline continuation).
## CLI Quick Start
Set the CLI path:
```bash
export SORA_CLI="<path-to-skill>/scripts/sora.py"
```
### Generate a video
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Close-up of a premium smartwatch on marble surface" \
--model sora-2 \
--size 1280x720 \
--seconds 8
```
### Generate and auto-download
```bash
uv run --with openai python "$SORA_CLI" create-and-poll \
--prompt "Product hero shot of wireless earbuds" \
--model sora-2-pro \
--size 1920x1080 \
--seconds 4 \
--download \
--out hero.mp4
```
### Dry-run (no API call)
```bash
python "$SORA_CLI" create --prompt "Test prompt" --dry-run
```
Full CLI reference: `references/cli.md`
## Authentication
- `OPENAI_API_KEY` must be set for live API calls.
- Never ask customers to paste their full key in chat — have them set it locally.
- If key is missing, guide them to [platform.openai.com/api-keys](https://platform.openai.com/api-keys).
- ChatGPT subscription OAuth tokens do NOT work (missing `api.videos.*` scopes).
## Models & Defaults
- **Default model:** `sora-2` (fast, flexible)
- **Premium model:** `sora-2-pro` (higher fidelity, required for 1080p)
- **Default size:** `1280x720`
- **Default duration:** `4` seconds
- **Allowed durations:** 4, 8, 12, 16, 20 seconds
### Size Support
| Model | Sizes |
|-------|-------|
| sora-2 | 1280x720, 720x1280 |
| sora-2-pro | 1280x720, 720x1280, 1024x1792, 1792x1024, 1920x1080, 1080x1920 |
## Prompt Augmentation
The CLI automatically reformats prompts into a structured production spec. Use CLI flags instead of writing long structured prompts:
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Premium headphones on display" \
--use-case "product teaser" \
--scene "dark studio, soft haze" \
--camera "85mm, slow orbit" \
--lighting "soft key, gentle rim" \
--seconds 8
```
If your prompt is already structured, add `--no-augment`.
## Marketing Templates
Ready-to-use prompt templates for common business video needs:
| Template | File | Best For |
|----------|------|----------|
| Product Demos | `templates/product-demo.md` | Product launches, feature showcases |
| Social Ads | `templates/social-ads.md` | Instagram, TikTok, LinkedIn clips |
| Brand Spots | `templates/brand-spots.md` | Brand identity, company culture |
| Launch Teasers | `templates/launch-teaser.md` | Pre-launch hype, coming soon |
## Guardrails (Enforced by API)
- Only content suitable for audiences under 18
- No copyrighted characters or music
- No real people (including public figures)
- Input images with human faces are rejected
- Character uploads are for non-human subjects only
## API Limitations
- Models: `sora-2` and `sora-2-pro` only
- Duration set via `seconds` parameter (4, 8, 12, 16, 20)
- Max 2 characters per generation
- Extensions: up to 20s each, 6 times max (120s total)
- Extensions do not support characters or image references
- Video creation is async — must poll for completion
- Download URLs expire after ~1 hour
- Content restrictions enforced server-side
## Reference Map
- **`references/cli.md`** — Full CLI command reference
- **`references/video-api.md`** — API parameters and endpoints
- **`references/prompting.md`** — Prompt engineering best practices
- **`references/troubleshooting.md`** — Common errors and fixes
- **`templates/product-demo.md`** — Product demo prompt templates
- **`templates/social-ads.md`** — Social ad prompt templates
- **`templates/brand-spots.md`** — Brand identity spot templates
- **`templates/launch-teaser.md`** — Launch teaser templates
FILE:package.json
{
"name": "@stomme/sora-video",
"version": "1.0.0",
"description": "AI video generation using OpenAI Sora 2 API with marketing-focused prompt templates",
"type": "module",
"main": "src/index.js",
"scripts": {
"test": "node --test tests/"
},
"keywords": ["sora", "video", "ai-generation", "marketing", "openai"],
"license": "UNLICENSED",
"private": true
}
FILE:references/cinematic-shots.md
# Cinematic shot templates
Use these for filmic, mood-forward clips. Keep one subject, one action, one camera move.
## Shot grammar (pick one)
- Static wide: locked-off, slow atmosphere changes
- Dolly-in: slow push toward subject
- Dolly-out: reveal more context
- Orbit: 15-45 degree arc around subject
- Lateral move: smooth left-right slide
- Crane: subtle vertical rise
- Handheld drift: gentle, controlled sway
## Default template
```
Use case: cinematic shot
Primary request: <subject + setting>
Scene/background: <location, time of day, atmosphere>
Subject: <main subject>
Action: <one clear action>
Camera: <shot type, lens, motion>
Lighting/mood: <key light + mood>
Color palette: <3-5 anchors>
Style/format: filmic, natural grain
Constraints: no logos, no text, no people
Avoid: jitter; flicker; oversharpening
```
## Example: moody exterior
```
Use case: cinematic shot
Primary request: a lone cabin on a cliff above the sea
Scene/background: foggy coastline at dawn, drifting mist
Subject: small wooden cabin with warm window glow
Action: light fog rolls past the cabin
Camera: slow dolly-in, 35mm, steady
Lighting/mood: moody, soft dawn light, subtle contrast
Color palette: deep blue, slate, warm amber
Constraints: no logos, no text, no people
```
## Example: intimate detail
```
Use case: cinematic detail
Primary request: close-up of a vinyl record spinning
Scene/background: dim room, soft lamp glow
Subject: record grooves and stylus
Action: slow rotation, subtle dust motes
Camera: macro, locked-off
Lighting/mood: warm, low-key, soft highlights
Color palette: warm amber, deep brown, charcoal
Constraints: no logos, no text
```
FILE:references/cli.md
# CLI Reference (`scripts/sora.py`)
Full command reference for the bundled Sora CLI. The CLI is adapted from Codex's production Sora 2 skill.
## Commands
| Command | Description |
|---------|-------------|
| `create` | Create a new video job |
| `create-and-poll` | Create, poll until complete, optionally download |
| `create-character` | Upload a reusable non-human character reference |
| `edit` | Edit an existing generated video by ID |
| `extend` | Continue a completed video |
| `poll` | Wait for an existing job to finish |
| `status` | Retrieve job status/details |
| `download` | Download video/thumbnail/spritesheet |
| `list` | List recent video jobs |
| `delete` | Delete a video job |
| `create-batch` | Create multiple jobs from JSONL input (local fan-out) |
| `remix` | Legacy remix endpoint (deprecated — use `edit`) |
Live API calls require `OPENAI_API_KEY`. `--dry-run` does not.
## Setup
```bash
export SORA_CLI="<path-to-sora-video-skill>/scripts/sora.py"
```
If uv cache fails with permission errors:
```bash
export UV_CACHE_DIR="/tmp/uv-cache"
```
## Defaults
- Model: `sora-2`
- Size: `1280x720`
- Seconds: `4`
- Variant: `video`
- Poll interval: `10` seconds
Allowed seconds: `4`, `8`, `12`, `16`, `20`
Allowed sizes:
- `sora-2`: `1280x720`, `720x1280`
- `sora-2-pro`: `1280x720`, `720x1280`, `1024x1792`, `1792x1024`, `1920x1080`, `1080x1920`
## Create
```bash
uv run --with openai python "$SORA_CLI" create \
--model sora-2 \
--prompt "Wide tracking shot of a teal coupe on a desert highway" \
--size 1280x720 \
--seconds 8
```
With input reference image:
```bash
uv run --with openai python "$SORA_CLI" create \
--model sora-2-pro \
--prompt "She turns around and smiles, then slowly walks out of frame." \
--size 1280x720 \
--seconds 8 \
--input-reference sample_720p.jpeg
```
With characters:
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Mossy, a moss-covered teapot mascot, rushes through a market at dusk." \
--character-id char_123 \
--seconds 8
```
With augmentation fields (instead of writing a structured prompt):
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Premium headphones on display" \
--use-case "product teaser" \
--scene "dark studio, soft haze" \
--camera "85mm, slow orbit" \
--lighting "soft key, gentle rim" \
--seconds 8
```
With pre-structured prompt (disable augmentation):
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt-file prompt.txt \
--no-augment \
--seconds 16
```
## Create and Poll
```bash
uv run --with openai python "$SORA_CLI" create-and-poll \
--model sora-2-pro \
--prompt "Close-up of steaming coffee on a wooden table" \
--size 1920x1080 \
--seconds 16 \
--download \
--variant video \
--out coffee.mp4
```
## Create Character
```bash
uv run --with openai python "$SORA_CLI" create-character \
--name Mossy \
--video-file character.mp4
```
## Edit
```bash
uv run --with openai python "$SORA_CLI" edit \
--id video_abc123 \
--prompt "Same shot and camera move; shift palette to teal, sand, rust."
```
## Extend
```bash
uv run --with openai python "$SORA_CLI" extend \
--id video_abc123 \
--seconds 8 \
--prompt "Continue as camera rises above rooftops revealing sunrise."
```
## Poll / Status / Download
```bash
uv run --with openai python "$SORA_CLI" poll --id video_abc123 --download --out out.mp4
uv run --with openai python "$SORA_CLI" status --id video_abc123
uv run --with openai python "$SORA_CLI" download --id video_abc123 --variant thumbnail --out thumb.webp
uv run --with openai python "$SORA_CLI" download --id video_abc123 --variant spritesheet --out sheet.jpg
```
## List / Delete
```bash
uv run --with openai python "$SORA_CLI" list --limit 20 --order asc
uv run --with openai python "$SORA_CLI" delete --id video_abc123
```
## Batch (Local Fan-Out)
```bash
cat > prompts.jsonl << 'EOF'
{"prompt":"Neon-lit rainy alley, slow dolly-in","seconds":"8"}
{"prompt":"Warm sunrise over misty lake, gentle pan","seconds":"8"}
EOF
uv run --with openai python "$SORA_CLI" create-batch \
--input prompts.jsonl \
--out-dir batch-output \
--concurrency 3
```
**Note:** `create-batch` is a local concurrent helper, NOT the official Batch API.
## JSON Output (`--json-out`)
All commands support `--json-out <path>` to save API responses to a file.
- `create-and-poll` writes `{ "create": ..., "final": ... }`
- In `--dry-run` mode, writes the request preview
- Extension `.json` added automatically if omitted
## Prompt Augmentation Fields
| Flag | Maps to |
|------|---------|
| `--use-case` | Use case |
| `--scene` | Scene/background |
| `--subject` | Subject |
| `--action` | Action |
| `--camera` | Camera |
| `--style` | Style/format |
| `--lighting` | Lighting/mood |
| `--palette` | Color palette |
| `--audio` | Audio |
| `--dialogue` | Dialogue |
| `--text` | Text (verbatim) |
| `--timing` | Timing/beats |
| `--constraints` | Constraints |
| `--negative` | Avoid |
Use `--no-augment` to disable and pass raw prompts through.
FILE:references/prompting.md
# Prompting best practices (Sora)
## Contents
- [Mindset & tradeoffs](#mindset--tradeoffs)
- [API-controlled params](#api-controlled-params)
- [Structure](#structure)
- [Specificity](#specificity)
- [Style & visual cues](#style--visual-cues)
- [Camera & composition](#camera--composition)
- [Motion & timing](#motion--timing)
- [Lighting & palette](#lighting--palette)
- [Character continuity](#character-continuity)
- [Multi-shot prompts](#multi-shot-prompts)
- [Ultra-detailed briefs](#ultra-detailed-briefs)
- [Image input](#image-input)
- [Constraints & invariants](#constraints--invariants)
- [Text, dialogue & audio](#text-dialogue--audio)
- [Avoiding artifacts](#avoiding-artifacts)
- [Editing & extensions](#editing--extensions)
- [Iterate deliberately](#iterate-deliberately)
## Mindset & tradeoffs
- Treat the prompt like a cinematography brief, not a contract.
- The same prompt can yield different results; rerun for variants.
- Short prompts give more creative freedom; longer prompts give more control.
- Shorter clips tend to follow instructions better; even though `16`s and `20`s are available, start shorter when precision matters.
## API-controlled params
- Model, size, seconds, and character IDs are controlled by API params, not prose.
- Put desired duration in the `seconds` param; the prompt cannot make a clip longer.
- `1920x1080` and `1080x1920` require `sora-2-pro`.
## Structure
- Use short labeled lines; omit sections that do not matter.
- Keep one main subject and one main action.
- Put timing in beats or counts if it matters.
- If you prefer a prose-first template, use:
```
<Prose scene description in plain language. Describe subject, setting, time of day, and key visual details.>
Cinematography:
Camera shot: <framing + angle>
Mood: <tone>
Actions:
- <clear action beat>
- <clear action beat>
Dialogue:
<short lines if needed>
```
## Specificity
- Name the subject and materials (metal, fabric, glass).
- Use camera language (lens, angle, shot type) for stability.
- Describe the environment with time of day and atmosphere.
## Style & visual cues
- Set style early (e.g., "1970s film", "IMAX-scale", "16mm black-and-white").
- Use visible nouns and verbs, not vague adjectives.
- Weak: "A beautiful street at night."
- Strong: "Wet asphalt, zebra crosswalk, neon signs reflecting in puddles."
## Camera & composition
- Prefer one camera move: dolly, orbit, lateral slide, or locked-off.
- Straight-on framing is best for UI and text.
- For close-ups, use longer lenses (85mm+); for wide scenes, 24-35mm.
- Depth of field is a strong lever: shallow for subject isolation, deep for context.
- Example framings: wide establishing, medium close-up, aerial wide, low angle.
- Example camera motions: slow tilt, gentle handheld drift, smooth lateral slide.
## Motion & timing
- Use short beats: "0-2s", "2-4s", "4-6s".
- Keep actions sequential, not simultaneous.
- For 4s clips, limit to 1-2 beats.
- Describe actions as counts or steps when possible (e.g., "takes four steps, pauses, turns in the final second").
## Lighting & palette
- Describe light quality and direction (soft window light, hard rim, backlight).
- Name 3-5 palette anchors to stabilize color across shots.
- If continuity matters, keep lighting logic consistent across clips.
## Character continuity
- Keep character descriptors consistent across shots; reuse phrasing.
- Avoid mixing competing traits that can shift identity or pose.
- When using uploaded character assets, mention the character name verbatim in the prompt.
- Use no more than two characters per generation.
- Character uploads work best from short non-human MP4 reference clips.
## Multi-shot prompts
- You can describe multiple shots in one prompt, but keep each shot block distinct.
- For each shot, specify one camera setup, one action, one lighting recipe.
- Treat each shot as a creative unit you can later edit or stitch.
## Ultra-detailed briefs
- Use when you need a specific, filmic look or strict continuity.
- Call out format/look, lensing/filters, grade/palette, lighting direction, texture, and sound.
- If needed, include a short shot list with timing beats.
## Image input
- Use an input image to lock composition, character design, or set dressing.
- The input image should match the target size and be jpg/png/webp.
- The image anchors the first frame; the prompt describes what happens next.
- If you lack a reference, generate one first and pass it as `input_reference`.
## Constraints & invariants
- State what must not change: "same shot", "same framing", "keep background".
- Repeat invariants in every edit to reduce drift.
- Use invariants sparingly in extensions; tell the model what should continue, not just what should stay frozen.
## Text, dialogue & audio
- Keep text short and specific; quote exact strings.
- Specify placement and avoid motion blur.
- For dialogue, use a dedicated block and keep lines short.
- Label speakers consistently for multi-character scenes.
- If silent, you can still add a small ambient sound cue to set rhythm.
- Sora can generate audio; include an `Audio:` line and a short dialogue block when needed.
- As a rule of thumb, 4s clips fit 1-2 short lines; 8s clips can handle a few more.
Example:
```
Audio: soft ambient café noise, clear warm voiceover
Dialogue:
<dialogue>
- Speaker: "Let's get started."
</dialogue>
```
## Avoiding artifacts
- Avoid multiple actions in 4-8 seconds.
- Keep camera motion smooth and limited.
- Add explicit negatives when needed: "avoid flicker", "avoid jitter", "no fast motion".
## Editing & extensions
- Prefer edits when the shot is mostly right and you want one targeted change.
- Prefer extensions when the existing clip should continue forward in time.
- For edits, change one thing at a time: palette, lighting, or action.
- For extensions, describe the next beat clearly and preserve motion continuity.
- If a shot misfires, simplify: freeze the camera, reduce action, clear background, then add complexity back in.
## Iterate deliberately
- Start simple, then add one constraint per iteration.
- If results look chaotic, reduce motion and simplify the scene.
- When a result is close, pin it as a reference and describe only the tweak.
FILE:references/sample-prompts.md
# Sample prompts (copy/paste)
Use these as starting points. Keep user-provided requirements and constraints; do not invent new creative elements.
For prompting principles (structure, invariants, iteration), see `references/prompting.md`.
## Contents
- [Product teaser (single shot)](#product-teaser-single-shot)
- [UI demo (screen recording style)](#ui-demo-screen-recording-style)
- [Cinematic detail shot](#cinematic-detail-shot)
- [Social ad (6s with beats)](#social-ad-6s-with-beats)
- [Character continuity shot](#character-continuity-shot)
- [Edit follow-up](#edit-follow-up)
- [Extension follow-up](#extension-follow-up)
- [Motion graphics explainer](#motion-graphics-explainer)
- [Ambient loop (atmosphere)](#ambient-loop-atmosphere)
## Product teaser (single shot)
```
Use case: product teaser
Primary request: close-up of a matte black wireless speaker on a stone pedestal
Scene/background: dark studio cyclorama, subtle haze
Subject: compact speaker with soft fabric texture
Action: slow 20-degree orbit over 4 seconds
Camera: 85mm, shallow depth of field, steady dolly
Lighting/mood: soft key, gentle rim, premium studio feel
Color palette: charcoal, slate, warm amber accents
Constraints: no logos, no text
Avoid: harsh bloom; oversharpening; clutter
```
## UI demo (screen recording style)
```
Use case: UI product demo
Primary request: a clean mobile budgeting app demo showing a weekly spend chart
Scene/background: neutral gradient backdrop
Subject: smartphone UI, centered, screen content crisp and legible
Action: tap the "Add expense" button, modal opens, amount typed, save
Camera: locked-off, straight-on, no tilt
Lighting/mood: soft studio light, minimal reflections
Color palette: off-white, slate, mint accent
Text (verbatim): "Add expense", "$24.50", "Groceries"
Constraints: no brand logos; keep UI text readable; avoid motion blur
```
## Cinematic detail shot
```
Use case: cinematic product detail
Primary request: macro shot of raindrops sliding across a car hood
Scene/background: night city bokeh, soft rain mist
Subject: glossy hood surface with water beads
Action: slow push-in over 4 seconds
Camera: 100mm macro, shallow depth of field
Lighting/mood: moody, high-contrast reflections, soft speculars
Color palette: deep navy, teal, silver highlights
Constraints: no logos, no text
Avoid: flicker; unstable reflections; excessive noise
```
## Social ad (6s with beats)
```
Use case: social ad
Primary request: minimal coffee subscription ad with three quick beats
Scene/background: warm kitchen counter, morning light
Subject: ceramic mug, coffee bag, steam
Action: beat 1 (0-2s) pour coffee; beat 2 (2-4s) steam rises; beat 3 (4-6s) mug slides to center
Camera: 50mm, gentle handheld drift
Lighting/mood: warm, cozy, natural light
Text (verbatim): "Fresh roast" (top-left), "Weekly delivery" (bottom-right)
Constraints: no logos; text must be legible; avoid fast motion
```
## Character continuity shot
```
Use case: mascot continuity
Primary request: Mossy, a moss-covered teapot mascot, rushes through a lantern-lit market at dusk
Scene/background: narrow alley, hanging lanterns, light haze
Subject: Mossy the moss-covered teapot mascot
Action: quick jog through the alley, glances toward camera near the end
Camera: 35mm, shoulder-height tracking shot, smooth lateral move
Lighting/mood: warm dusk practicals, cinematic glow
Color palette: moss green, warm amber, charcoal
Constraints: keep Mossy's silhouette, moss texture, and teapot proportions consistent
Avoid: flicker; warped limbs; identity drift
```
## Edit follow-up
```
Primary request: same shot and camera move; change only the palette to teal, sand, and rust with a warmer backlight
Constraints: keep the subject, framing, and motion unchanged
Avoid: new objects; reframing; speed changes
```
## Extension follow-up
```
Primary request: continue the same shot as the camera rises above the rooftops and reveals sunrise over the city
Action: maintain the existing motion, then gently tilt upward into the skyline reveal
Lighting/mood: dawn light growing warmer through the extension
Constraints: preserve scene continuity, camera direction, and overall pacing
Avoid: abrupt cuts; jumpy motion; sudden subject changes
```
## Motion graphics explainer
```
Use case: explainer clip
Primary request: clean motion-graphics animation showing data flowing into a dashboard
Scene/background: soft gradient background
Subject: abstract nodes and lines, simple dashboard cards
Action: nodes connect, data pulses, cards fill with charts
Camera: locked-off, no depth, flat design
Lighting/mood: minimal, modern
Color palette: off-white, graphite, teal, coral accents
Constraints: no logos; keep shapes simple; avoid heavy texture
```
## Ambient loop (atmosphere)
```
Use case: ambient background loop
Primary request: fog drifting through a pine forest at dawn
Scene/background: tall pines, soft fog layers, distant hills
Subject: drifting fog and light rays
Action: slow lateral drift, subtle light change
Camera: wide, locked-off, no tilt
Lighting/mood: calm, soft dawn light
Color palette: muted greens, cool gray, pale gold
Constraints: no text, no logos, no people
Avoid: fast motion; flicker; abrupt lighting shifts
```
FILE:references/troubleshooting.md
# Troubleshooting
## Job fails with size or seconds errors
- Cause: size is not supported by the chosen model, or seconds is outside `4`, `8`, `12`, `16`, `20`.
- Fix: match size to model; use `sora-2-pro` for `1920x1080` or `1080x1920`.
## Docs and SDK disagree on the latest limits or helpers
- Cause: the March 2026 Sora guide/changelog is ahead of some typed SDK/API-reference surfaces.
- Fix: follow the latest guide/changelog and use the bundled CLI, which bridges new flows through the official client’s low-level methods.
## `edit`, `extend`, or `create-character` isn't available in your installed Python SDK
- Cause: the published SDK may not expose new Sora helpers yet.
- Fix: use `scripts/sora.py`; it uses the official OpenAI client directly for those endpoints.
## openai SDK not installed
- Cause: running `python "$SORA_CLI" ...` without the OpenAI SDK available.
- Fix: run with `uv run --with openai python "$SORA_CLI" ...`.
## uv cache permission error
- Cause: uv cache directory is not writable in CI or sandboxed environments.
- Fix: set `UV_CACHE_DIR=/tmp/uv-cache` (or another writable path) before running `uv`.
## Prompt shell escaping issues
- Cause: multi-line prompts or quotes break the shell.
- Fix: use `--prompt-file prompt.txt`.
## Prompt looks double-wrapped ("Primary request: Use case: ...")
- Cause: you structured the prompt manually but left CLI augmentation on.
- Fix: add `--no-augment`, or use the CLI fields (`--use-case`, `--scene`, etc.) instead of pre-formatting.
## Input reference rejected
- Cause: the file is not jpg/png/webp, includes a human face, or does not match the target size.
- Fix: convert to jpg/png/webp, remove faces, and resize to match `--size`.
## Character continuity is weak
- Cause: the character clip is too long, mismatched in aspect ratio, outside the skill's non-human character workflow, or the prompt never names the character.
- Fix: use a short non-human MP4, match aspect ratio to the target shot, and mention the character name verbatim in the prompt.
## Extension looks jumpy or drifts
- Cause: the continuation prompt changes too many things at once, or asks for a hard scene break.
- Fix: describe the next beat only, preserve motion direction, and avoid introducing unrelated subjects or abrupt camera changes.
## Remix drifts from the original
- Cause: remix is a legacy endpoint and too many changes were requested at once.
- Fix: prefer `edit`, state invariants explicitly, and change one element at a time.
## Download fails or returns expired URL
- Cause: normal download URLs expire after about 1 hour.
- Fix: re-download while the link is fresh and copy the asset to your own storage promptly.
## Video completes but looks unstable or flickers
- Cause: multiple actions, aggressive camera motion, or overly long prompt timing for the clip length.
- Fix: reduce to one main action and one camera move; keep beats simple; add constraints like `avoid flicker` or `stable motion`.
## Text is unreadable
- Cause: text is too long, too small, or moving.
- Fix: shorten text, keep the camera locked-off, and avoid fast motion.
## Job stuck in `queued` or `in_progress`
- Cause: temporary queue delays or slower higher-resolution renders.
- Fix: increase timeout, poll less aggressively, and expect longer waits for `16`/`20` second or 1080p jobs.
## `create-batch` is not behaving like the Batch API
- Cause: `create-batch` is a local concurrent helper, not the official Batch API.
- Fix: use the Files + Batches APIs for true offline batching; use `create-batch` only for immediate local fan-out.
## Cleanup blocked by sandbox policy
- Cause: some environments block `rm`.
- Fix: skip cleanup, or truncate temporary files instead of deleting them.
FILE:references/video-api.md
# Sora Video API quick reference
Keep this file short; the full source of truth is the latest OpenAI Sora guide plus the API changelog.
## Source-of-truth note
- The March 2026 changelog and Sora guide added characters, 16s/20s clips, `1920x1080` / `1080x1920` on `sora-2-pro`, extensions, and edits.
- Some typed SDK and API-reference pages may still show the older `4`/`8`/`12` and pre-1080p enums.
- If they disagree, follow the latest guide/changelog and use the bundled CLI, which bridges the SDK lag with low-level official-client calls.
## Models
- `sora-2`: faster, flexible iteration
- `sora-2-pro`: higher fidelity, slower, more expensive
## Sizes (by model)
- `sora-2`: `1280x720`, `720x1280`
- `sora-2-pro`: `1280x720`, `720x1280`, `1024x1792`, `1792x1024`, `1920x1080`, `1080x1920`
- Use `sora-2-pro` for 1080p exports.
## Duration
- `seconds`: `"4"`, `"8"`, `"12"`, `"16"`, `"20"`
- Use shorter clips first when iterating on motion, timing, or composition.
## Input references
- `input_reference` guides the first frame of a generation.
- Multipart requests use an uploaded image file.
- JSON requests use an object with exactly one of `file_id` or `image_url`.
- Supported image formats: jpg/jpeg, png, webp.
- Input references should match the target `size`.
## Characters
- Create reusable non-human characters via `POST /v1/videos/characters`.
- Character source clips work best as short MP4s (`2`-`4`s) in `16:9` or `9:16`, at `720p`-`1080p`.
- Reference up to two characters per generation with `characters: [{"id": "..."}]`.
- Mention the character name verbatim in the prompt; the ID alone is not enough.
- Characters can be combined with `input_reference`.
- In this skill, character workflows are limited to non-human subjects.
## Edits vs remix
- Preferred: `POST /v1/videos/edits`
- Legacy/deprecated: `POST /v1/videos/{video_id}/remix`
- Use edits for new integrations.
- In this skill, use edits for existing generated video IDs only.
## Extensions
- Use `POST /v1/videos/extensions` to continue a completed video.
- Each extension can add up to `20` seconds.
- A single video can be extended up to six times, for a maximum total length of `120` seconds.
- Extensions do not support characters or image references.
## Jobs and status
- Creation, edit, and extension jobs are async.
- Common statuses: `queued`, `in_progress`, `completed`, `failed`
- Poll every `10`-`20`s or use webhooks.
- Webhook events: `video.completed`, `video.failed`
## Core endpoints
- `POST /videos`: create
- `POST /videos/characters`: create a reusable character
- `POST /videos/edits`: edit an existing generated video by ID
- `POST /videos/extensions`: extend a completed video
- `GET /videos/{id}`: retrieve status/details
- `GET /videos/{id}/content`: download content
- `GET /videos`: list
- `DELETE /videos/{id}`: delete
- `POST /videos/{id}/remix`: legacy/deprecated
## Download variants
- `video` -> mp4
- `thumbnail` -> webp
- `spritesheet` -> jpg
Download URLs expire after about 1 hour; save assets to your own storage promptly.
## Batch API
- The official Batch API supports `POST /v1/videos` only.
- Batch requests must use JSON, not multipart.
- Upload assets ahead of time and reference them in the JSON body.
- For image-guided Batch jobs, use JSON `input_reference` with `file_id` or `image_url`.
- Batch-generated videos remain downloadable for up to 24 hours after the batch completes.
- The bundled `scripts/sora.py create-batch` command is a local fan-out helper, not the official Batch API.
## Guardrails
- Only content suitable for audiences under 18
- No copyrighted characters or copyrighted music
- No real people (including public figures)
- Input images with human faces are currently rejected
FILE:scripts/sora.py
#!/usr/bin/env python3
"""Create and manage Sora videos with the OpenAI Video API.
Defaults to sora-2 and a structured prompt augmentation workflow.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
import re
import sys
import time
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
DEFAULT_MODEL = "sora-2"
DEFAULT_SIZE = "1280x720"
DEFAULT_SECONDS = "4"
DEFAULT_POLL_INTERVAL = 10.0
DEFAULT_VARIANT = "video"
DEFAULT_CONCURRENCY = 3
DEFAULT_MAX_ATTEMPTS = 3
ALLOWED_MODELS = {"sora-2", "sora-2-pro"}
ALLOWED_SIZES_SORA2 = {"1280x720", "720x1280"}
ALLOWED_SIZES_SORA2_PRO = {
"1280x720",
"720x1280",
"1024x1792",
"1792x1024",
"1080x1920",
"1920x1080",
}
ALLOWED_SECONDS = {"4", "8", "12", "16", "20"}
ALLOWED_VARIANTS = {"video", "thumbnail", "spritesheet"}
ALLOWED_ORDERS = {"asc", "desc"}
ALLOWED_INPUT_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_VIDEO_EXTS = {".mp4"}
TERMINAL_STATUSES = {"completed", "failed", "canceled", "expired"}
VARIANT_EXTENSIONS = {"video": ".mp4", "thumbnail": ".webp", "spritesheet": ".jpg"}
MAX_BATCH_JOBS = 200
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _warn(message: str) -> None:
print(f"Warning: {message}", file=sys.stderr)
def _ensure_api_key(dry_run: bool) -> None:
if os.getenv("OPENAI_API_KEY"):
print("OPENAI_API_KEY is set.", file=sys.stderr)
return
if dry_run:
_warn("OPENAI_API_KEY is not set; dry-run only.")
return
_die("OPENAI_API_KEY is not set. Export it before running.")
def _read_prompt(prompt: Optional[str], prompt_file: Optional[str]) -> str:
if prompt and prompt_file:
_die("Use --prompt or --prompt-file, not both.")
if prompt_file:
path = Path(prompt_file)
if not path.exists():
_die(f"Prompt file not found: {path}")
return path.read_text(encoding="utf-8").strip()
if prompt:
return prompt.strip()
_die("Missing prompt. Use --prompt or --prompt-file.")
return "" # unreachable
def _normalize_model(model: Optional[str]) -> str:
value = (model or DEFAULT_MODEL).strip().lower()
if value not in ALLOWED_MODELS:
_die("model must be one of: sora-2, sora-2-pro")
return value
def _normalize_size(size: Optional[str], model: str) -> str:
value = (size or DEFAULT_SIZE).strip().lower()
allowed = ALLOWED_SIZES_SORA2 if model == "sora-2" else ALLOWED_SIZES_SORA2_PRO
if value not in allowed:
allowed_list = ", ".join(sorted(allowed))
_die(f"size must be one of: {allowed_list} for model {model}")
return value
def _normalize_seconds(seconds: Optional[Union[int, str]]) -> str:
if seconds is None:
value = DEFAULT_SECONDS
elif isinstance(seconds, int):
value = str(seconds)
else:
value = str(seconds).strip()
if value not in ALLOWED_SECONDS:
_die("seconds must be one of: 4, 8, 12, 16, 20")
return value
def _normalize_variant(variant: Optional[str]) -> str:
value = (variant or DEFAULT_VARIANT).strip().lower()
if value not in ALLOWED_VARIANTS:
_die("variant must be one of: video, thumbnail, spritesheet")
return value
def _normalize_order(order: Optional[str]) -> Optional[str]:
if order is None:
return None
value = order.strip().lower()
if value not in ALLOWED_ORDERS:
_die("order must be one of: asc, desc")
return value
def _normalize_poll_interval(interval: Optional[float]) -> float:
value = float(interval if interval is not None else DEFAULT_POLL_INTERVAL)
if value <= 0:
_die("poll-interval must be > 0")
return value
def _normalize_timeout(timeout: Optional[float]) -> Optional[float]:
if timeout is None:
return None
value = float(timeout)
if value <= 0:
_die("timeout must be > 0")
return value
def _default_out_path(variant: str) -> Path:
if variant == "video":
return Path("video.mp4")
if variant == "thumbnail":
return Path("thumbnail.webp")
return Path("spritesheet.jpg")
def _normalize_out_path(out: Optional[str], variant: str) -> Path:
expected_ext = VARIANT_EXTENSIONS[variant]
if not out:
return _default_out_path(variant)
path = Path(out)
if path.suffix == "":
return path.with_suffix(expected_ext)
if path.suffix.lower() != expected_ext:
_warn(f"Output extension {path.suffix} does not match {expected_ext} for {variant}.")
return path
def _normalize_json_out(out: Optional[str], default_name: str) -> Optional[Path]:
if not out:
return None
raw = str(out)
if raw.endswith("/") or raw.endswith(os.sep):
return Path(raw) / default_name
path = Path(out)
if path.exists() and path.is_dir():
return path / default_name
if path.suffix == "":
path = path.with_suffix(".json")
return path
def _normalize_input_reference_object(value: Any) -> Dict[str, str]:
if not isinstance(value, dict):
_die("input_reference object must be a JSON object with file_id or image_url.")
file_id = str(value.get("file_id", "")).strip()
image_url = str(value.get("image_url", "")).strip()
if bool(file_id) == bool(image_url):
_die("input_reference object must include exactly one of file_id or image_url.")
if file_id:
return {"file_id": file_id}
return {"image_url": image_url}
def _normalize_input_reference(
*,
value: Any = None,
path: Optional[str] = None,
file_id: Optional[str] = None,
image_url: Optional[str] = None,
) -> Tuple[Optional[str], Optional[Dict[str, str]]]:
if value is not None:
if any(item is not None for item in (path, file_id, image_url)):
_die(
"Use either input_reference or explicit input-reference path/file-id/url fields, not both."
)
if isinstance(value, str):
path = value
elif isinstance(value, dict):
return None, _normalize_input_reference_object(value)
else:
_die("input_reference must be a file path string or a JSON object.")
provided = [bool(path), bool(file_id), bool(image_url)]
if sum(provided) > 1:
_die("Use only one of --input-reference, --input-reference-file-id, or --input-reference-url.")
if path:
return str(path), None
if file_id:
return None, {"file_id": str(file_id).strip()}
if image_url:
return None, {"image_url": str(image_url).strip()}
return None, None
def _normalize_characters(raw: Any) -> Optional[List[Dict[str, str]]]:
if raw is None:
return None
items: List[Any]
if isinstance(raw, str):
items = [part.strip() for part in raw.split(",") if part.strip()]
elif isinstance(raw, (list, tuple)):
items = list(raw)
else:
_die("characters must be a list of IDs, a comma-separated string, or objects with an id field.")
return None
if not items:
return None
normalized: List[Dict[str, str]] = []
for item in items:
if isinstance(item, str):
char_id = item.strip()
elif isinstance(item, dict):
char_id = str(item.get("id", "")).strip()
else:
_die("Each character must be a string ID or an object with an id field.")
return None
if not char_id:
_die("Character IDs must be non-empty.")
normalized.append({"id": char_id})
if len(normalized) > 2:
_die("A single video can include at most 2 characters.")
return normalized
def _open_input_reference(path: Optional[str]):
if not path:
return _NullContext()
p = Path(path)
if not p.exists():
_die(f"Input reference not found: {p}")
if p.suffix.lower() not in ALLOWED_INPUT_EXTS:
_warn("Input reference should be jpeg, png, or webp.")
return _SingleFile(p)
def _open_video_upload(path: Optional[str], *, label: str) -> Any:
if not path:
return _NullContext()
p = Path(path)
if not p.exists():
_die(f"{label} not found: {p}")
if p.suffix.lower() not in ALLOWED_VIDEO_EXTS:
_warn(f"{label} should usually be an MP4 file.")
return _SingleFile(p)
def _create_client():
try:
from openai import OpenAI
except ImportError:
_die("openai SDK not installed. Run with `uv run --with openai` or install with `uv pip install openai`.")
return OpenAI()
def _create_async_client():
try:
from openai import AsyncOpenAI
except ImportError:
try:
import openai as _openai # noqa: F401
except ImportError:
_die("openai SDK not installed. Run with `uv run --with openai` or install with `uv pip install openai`.")
_die(
"AsyncOpenAI not available in this openai SDK version. Upgrade with `uv pip install -U openai`."
)
return AsyncOpenAI()
def _make_request_options(*, multipart: bool) -> Dict[str, Any]:
from openai.resources.videos import make_request_options
headers = {"Content-Type": "multipart/form-data"} if multipart else None
return make_request_options(extra_headers=headers)
def _video_post(
client: Any,
path: str,
payload: Dict[str, Any],
*,
files: Optional[List[Tuple[str, Any]]] = None,
) -> Any:
return client.post(
path,
cast_to=dict,
body=payload,
files=files,
options=_make_request_options(multipart=bool(files)),
)
async def _async_video_post(
client: Any,
path: str,
payload: Dict[str, Any],
*,
files: Optional[List[Tuple[str, Any]]] = None,
) -> Any:
return await client.post(
path,
cast_to=dict,
body=payload,
files=files,
options=_make_request_options(multipart=bool(files)),
)
def _to_dict(obj: Any) -> Any:
if isinstance(obj, dict):
return obj
if hasattr(obj, "model_dump"):
return obj.model_dump()
if hasattr(obj, "dict"):
return obj.dict()
if hasattr(obj, "__dict__"):
return obj.__dict__
return obj
def _print_json(obj: Any) -> None:
print(json.dumps(_to_dict(obj), indent=2, sort_keys=True))
def _print_request(payload: Dict[str, Any]) -> None:
print(json.dumps(payload, indent=2, sort_keys=True))
def _slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value).strip("-")
return value[:60] if value else "job"
def _normalize_job(job: Any, idx: int) -> Dict[str, Any]:
if isinstance(job, str):
prompt = job.strip()
if not prompt:
_die(f"Empty prompt at job {idx}")
return {"prompt": prompt}
if isinstance(job, dict):
if "prompt" not in job or not str(job["prompt"]).strip():
_die(f"Missing prompt for job {idx}")
return job
_die(f"Invalid job at index {idx}: expected string or object.")
return {} # unreachable
def _read_jobs_jsonl(path: str) -> List[Dict[str, Any]]:
p = Path(path)
if not p.exists():
_die(f"Input file not found: {p}")
jobs: List[Dict[str, Any]] = []
for line_no, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
try:
item: Any
if line.startswith("{"):
item = json.loads(line)
else:
item = line
jobs.append(_normalize_job(item, idx=line_no))
except json.JSONDecodeError as exc:
_die(f"Invalid JSON on line {line_no}: {exc}")
if not jobs:
_die("No jobs found in input file.")
if len(jobs) > MAX_BATCH_JOBS:
_die(f"Too many jobs ({len(jobs)}). Max is {MAX_BATCH_JOBS}.")
return jobs
def _merge_non_null(dst: Dict[str, Any], src: Dict[str, Any]) -> Dict[str, Any]:
merged = dict(dst)
for k, v in src.items():
if v is not None:
merged[k] = v
return merged
def _job_output_path(out_dir: Path, idx: int, prompt: str, explicit_out: Optional[str]) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
if explicit_out:
path = Path(explicit_out)
if path.suffix == "":
path = path.with_suffix(".json")
return out_dir / path.name
slug = _slugify(prompt[:80])
return out_dir / f"{idx:03d}-{slug}.json"
def _extract_retry_after_seconds(exc: Exception) -> Optional[float]:
for attr in ("retry_after", "retry_after_seconds"):
val = getattr(exc, attr, None)
if isinstance(val, (int, float)) and val >= 0:
return float(val)
msg = str(exc)
m = re.search(r"retry[- ]after[:= ]+([0-9]+(?:\\.[0-9]+)?)", msg, re.IGNORECASE)
if m:
try:
return float(m.group(1))
except Exception:
return None
return None
def _is_rate_limit_error(exc: Exception) -> bool:
name = exc.__class__.__name__.lower()
if "ratelimit" in name or "rate_limit" in name:
return True
msg = str(exc).lower()
return "429" in msg or "rate limit" in msg or "too many requests" in msg
def _is_transient_error(exc: Exception) -> bool:
if _is_rate_limit_error(exc):
return True
name = exc.__class__.__name__.lower()
if "timeout" in name or "timedout" in name or "tempor" in name:
return True
msg = str(exc).lower()
return "timeout" in msg or "timed out" in msg or "connection reset" in msg
def _fields_from_args(args: argparse.Namespace) -> Dict[str, Optional[str]]:
return {
"use_case": getattr(args, "use_case", None),
"scene": getattr(args, "scene", None),
"subject": getattr(args, "subject", None),
"action": getattr(args, "action", None),
"camera": getattr(args, "camera", None),
"style": getattr(args, "style", None),
"lighting": getattr(args, "lighting", None),
"palette": getattr(args, "palette", None),
"audio": getattr(args, "audio", None),
"dialogue": getattr(args, "dialogue", None),
"text": getattr(args, "text", None),
"timing": getattr(args, "timing", None),
"constraints": getattr(args, "constraints", None),
"negative": getattr(args, "negative", None),
}
def _augment_prompt_fields(augment: bool, prompt: str, fields: Dict[str, Optional[str]]) -> str:
if not augment:
return prompt
sections: List[str] = []
if fields.get("use_case"):
sections.append(f"Use case: {fields['use_case']}")
sections.append(f"Primary request: {prompt}")
if fields.get("scene"):
sections.append(f"Scene/background: {fields['scene']}")
if fields.get("subject"):
sections.append(f"Subject: {fields['subject']}")
if fields.get("action"):
sections.append(f"Action: {fields['action']}")
if fields.get("camera"):
sections.append(f"Camera: {fields['camera']}")
if fields.get("lighting"):
sections.append(f"Lighting/mood: {fields['lighting']}")
if fields.get("palette"):
sections.append(f"Color palette: {fields['palette']}")
if fields.get("style"):
sections.append(f"Style/format: {fields['style']}")
if fields.get("timing"):
sections.append(f"Timing/beats: {fields['timing']}")
if fields.get("audio"):
sections.append(f"Audio: {fields['audio']}")
if fields.get("text"):
sections.append(f"Text (verbatim): \"{fields['text']}\"")
if fields.get("dialogue"):
dialogue = fields["dialogue"].strip()
sections.append("Dialogue:\n<dialogue>\n" + dialogue + "\n</dialogue>")
if fields.get("constraints"):
sections.append(f"Constraints: {fields['constraints']}")
if fields.get("negative"):
sections.append(f"Avoid: {fields['negative']}")
return "\n".join(sections)
def _augment_prompt(args: argparse.Namespace, prompt: str) -> str:
fields = _fields_from_args(args)
return _augment_prompt_fields(args.augment, prompt, fields)
def _get_status(video: Any) -> Optional[str]:
if isinstance(video, dict):
for key in ("status", "state"):
if key in video and isinstance(video[key], str):
return video[key]
data = video.get("data") if isinstance(video.get("data"), dict) else None
if data:
for key in ("status", "state"):
if key in data and isinstance(data[key], str):
return data[key]
return None
for key in ("status", "state"):
val = getattr(video, key, None)
if isinstance(val, str):
return val
return None
def _get_video_id(video: Any) -> Optional[str]:
if isinstance(video, dict):
if isinstance(video.get("id"), str):
return video["id"]
data = video.get("data") if isinstance(video.get("data"), dict) else None
if data and isinstance(data.get("id"), str):
return data["id"]
return None
vid = getattr(video, "id", None)
return vid if isinstance(vid, str) else None
def _poll_video(
client: Any,
video_id: str,
*,
poll_interval: float,
timeout: Optional[float],
) -> Any:
start = time.time()
last_status: Optional[str] = None
while True:
video = client.videos.retrieve(video_id)
status = _get_status(video) or "unknown"
if status != last_status:
print(f"Status: {status}", file=sys.stderr)
last_status = status
if status in TERMINAL_STATUSES:
return video
if timeout is not None and (time.time() - start) > timeout:
_die(f"Timed out after {timeout:.1f}s waiting for {video_id}")
time.sleep(poll_interval)
def _download_content(client: Any, video_id: str, variant: str) -> Any:
content = client.videos.download_content(video_id, variant=variant)
if hasattr(content, "write_to_file"):
return content
if hasattr(content, "read"):
return content.read()
if isinstance(content, (bytes, bytearray)):
return bytes(content)
if hasattr(content, "content"):
return content.content
return content
def _write_download(data: Any, out_path: Path, *, force: bool) -> None:
if out_path.exists() and not force:
_die(f"Output exists: {out_path} (use --force to overwrite)")
if hasattr(data, "write_to_file"):
data.write_to_file(out_path)
print(f"Wrote {out_path}")
return
if hasattr(data, "read"):
out_path.write_bytes(data.read())
print(f"Wrote {out_path}")
return
out_path.write_bytes(data)
print(f"Wrote {out_path}")
def _build_create_payload(args: argparse.Namespace, prompt: str) -> Dict[str, Any]:
model = _normalize_model(args.model)
size = _normalize_size(args.size, model)
seconds = _normalize_seconds(args.seconds)
payload: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"size": size,
"seconds": seconds,
}
characters = _normalize_characters(getattr(args, "character_id", None))
if characters:
payload["characters"] = characters
_, input_reference_json = _normalize_input_reference(
path=getattr(args, "input_reference", None),
file_id=getattr(args, "input_reference_file_id", None),
image_url=getattr(args, "input_reference_url", None),
)
if input_reference_json is not None:
payload["input_reference"] = input_reference_json
return payload
def _prepare_job_payload(
args: argparse.Namespace,
job: Dict[str, Any],
base_fields: Dict[str, Optional[str]],
base_payload: Dict[str, Any],
) -> Tuple[Dict[str, Any], Optional[str], str]:
prompt = str(job["prompt"]).strip()
fields = _merge_non_null(base_fields, job.get("fields", {}))
fields = _merge_non_null(fields, {k: job.get(k) for k in base_fields.keys()})
augmented = _augment_prompt_fields(args.augment, prompt, fields)
payload = dict(base_payload)
payload["prompt"] = augmented
payload = _merge_non_null(payload, {k: job.get(k) for k in base_payload.keys()})
payload = {k: v for k, v in payload.items() if v is not None}
model = _normalize_model(payload.get("model"))
size = _normalize_size(payload.get("size"), model)
seconds = _normalize_seconds(payload.get("seconds"))
payload["model"] = model
payload["size"] = size
payload["seconds"] = seconds
raw_characters: Any = payload.get("characters")
if "characters" in job:
raw_characters = job.get("characters")
elif "character_ids" in job:
raw_characters = job.get("character_ids")
characters = _normalize_characters(raw_characters)
if characters:
payload["characters"] = characters
else:
payload.pop("characters", None)
default_input_ref_path, default_input_ref_json = _normalize_input_reference(
path=getattr(args, "input_reference", None),
file_id=getattr(args, "input_reference_file_id", None),
image_url=getattr(args, "input_reference_url", None),
)
input_ref_path = default_input_ref_path
input_ref_json = dict(default_input_ref_json) if default_input_ref_json else None
if any(
key in job
for key in (
"input_reference",
"input_reference_path",
"input_reference_file",
"input_reference_file_id",
"input_reference_url",
)
):
input_ref_path, input_ref_json = _normalize_input_reference(
value=job.get("input_reference"),
path=job.get("input_reference_path") or job.get("input_reference_file"),
file_id=job.get("input_reference_file_id"),
image_url=job.get("input_reference_url"),
)
if input_ref_json is not None:
payload["input_reference"] = input_ref_json
else:
payload.pop("input_reference", None)
return payload, input_ref_path, prompt
def _write_json(path: Path, obj: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(_to_dict(obj), indent=2, sort_keys=True), encoding="utf-8")
print(f"Wrote {path}")
def _write_json_out(out_path: Optional[Path], obj: Any) -> None:
if out_path is None:
return
_write_json(out_path, obj)
async def _create_one_with_retries(
client: Any,
payload: Dict[str, Any],
*,
files: Optional[List[Tuple[str, Any]]] = None,
attempts: int,
job_label: str,
) -> Any:
last_exc: Optional[Exception] = None
for attempt in range(1, attempts + 1):
try:
return await _async_video_post(client, "/videos", payload, files=files)
except Exception as exc:
last_exc = exc
if not _is_transient_error(exc):
raise
if attempt == attempts:
raise
sleep_s = _extract_retry_after_seconds(exc)
if sleep_s is None:
sleep_s = min(60.0, 2.0**attempt)
print(
f"{job_label} attempt {attempt}/{attempts} failed ({exc.__class__.__name__}); retrying in {sleep_s:.1f}s",
file=sys.stderr,
)
await asyncio.sleep(sleep_s)
raise last_exc or RuntimeError("unknown error")
async def _run_create_batch(args: argparse.Namespace) -> int:
jobs = _read_jobs_jsonl(args.input)
out_dir = Path(args.out_dir)
base_fields = _fields_from_args(args)
base_payload = {
"model": args.model,
"size": args.size,
"seconds": args.seconds,
"characters": _normalize_characters(getattr(args, "character_id", None)),
}
if args.dry_run:
for i, job in enumerate(jobs, start=1):
payload, input_ref, prompt = _prepare_job_payload(args, job, base_fields, base_payload)
out_path = _job_output_path(out_dir, i, prompt, job.get("out"))
preview = dict(payload)
if input_ref:
preview["input_reference"] = input_ref
_print_request(
{
"endpoint": "/v1/videos",
"job": i,
"output": str(out_path),
**preview,
}
)
return 0
client = _create_async_client()
sem = asyncio.Semaphore(args.concurrency)
any_failed = False
async def run_job(i: int, job: Dict[str, Any]) -> Tuple[int, Optional[str]]:
nonlocal any_failed
payload, input_ref, prompt = _prepare_job_payload(args, job, base_fields, base_payload)
job_label = f"[job {i}/{len(jobs)}]"
out_path = _job_output_path(out_dir, i, prompt, job.get("out"))
try:
async with sem:
print(f"{job_label} starting", file=sys.stderr)
started = time.time()
with _open_input_reference(input_ref) as ref:
files = [("input_reference", ref)] if ref is not None else None
result = await _create_one_with_retries(
client,
payload,
files=files,
attempts=args.max_attempts,
job_label=job_label,
)
elapsed = time.time() - started
print(f"{job_label} completed in {elapsed:.1f}s", file=sys.stderr)
_write_json(out_path, result)
return i, None
except Exception as exc:
any_failed = True
print(f"{job_label} failed: {exc}", file=sys.stderr)
if args.fail_fast:
raise
return i, str(exc)
tasks = [asyncio.create_task(run_job(i, job)) for i, job in enumerate(jobs, start=1)]
try:
await asyncio.gather(*tasks)
except Exception:
for t in tasks:
if not t.done():
t.cancel()
raise
return 1 if any_failed else 0
def _create_batch(args: argparse.Namespace) -> None:
exit_code = asyncio.run(_run_create_batch(args))
if exit_code:
raise SystemExit(exit_code)
def _cmd_create(args: argparse.Namespace) -> int:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
payload = _build_create_payload(args, prompt)
input_reference_path, _ = _normalize_input_reference(
path=args.input_reference,
file_id=args.input_reference_file_id,
image_url=args.input_reference_url,
)
json_out = _normalize_json_out(args.json_out, "create.json")
if args.dry_run:
preview = dict(payload)
if input_reference_path:
preview["input_reference"] = input_reference_path
_print_request({"endpoint": "/v1/videos", **preview})
_write_json_out(json_out, {"dry_run": True, "request": {"endpoint": "/v1/videos", **preview}})
return 0
client = _create_client()
with _open_input_reference(input_reference_path) as input_ref:
files = [("input_reference", input_ref)] if input_ref is not None else None
video = _video_post(client, "/videos", payload, files=files)
_print_json(video)
_write_json_out(json_out, video)
return 0
def _cmd_create_and_poll(args: argparse.Namespace) -> int:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
payload = _build_create_payload(args, prompt)
input_reference_path, _ = _normalize_input_reference(
path=args.input_reference,
file_id=args.input_reference_file_id,
image_url=args.input_reference_url,
)
json_out = _normalize_json_out(args.json_out, "create-and-poll.json")
if args.dry_run:
preview = dict(payload)
if input_reference_path:
preview["input_reference"] = input_reference_path
_print_request({"endpoint": "/v1/videos", **preview})
print("Would poll for completion.")
if args.download:
variant = _normalize_variant(args.variant)
out_path = _normalize_out_path(args.out, variant)
print(f"Would download variant={variant} to {out_path}")
if json_out:
dry_bundle: Dict[str, Any] = {
"dry_run": True,
"request": {"endpoint": "/v1/videos", **preview},
"poll": True,
}
if args.download:
dry_bundle["download"] = {
"variant": variant,
"out": str(out_path),
}
_write_json_out(json_out, dry_bundle)
return 0
client = _create_client()
with _open_input_reference(input_reference_path) as input_ref:
files = [("input_reference", input_ref)] if input_ref is not None else None
video = _video_post(client, "/videos", payload, files=files)
_print_json(video)
video_id = _get_video_id(video)
if not video_id:
_die("Could not determine video id from create response.")
poll_interval = _normalize_poll_interval(args.poll_interval)
timeout = _normalize_timeout(args.timeout)
final_video = _poll_video(
client,
video_id,
poll_interval=poll_interval,
timeout=timeout,
)
_print_json(final_video)
if args.download:
status = _get_status(final_video) or "unknown"
if status != "completed":
_die(f"Video status is {status}; download is available only after completion.")
variant = _normalize_variant(args.variant)
out_path = _normalize_out_path(args.out, variant)
data = _download_content(client, video_id, variant)
_write_download(data, out_path, force=args.force)
if json_out:
_write_json_out(
json_out,
{"create": _to_dict(video), "final": _to_dict(final_video)},
)
return 0
def _cmd_poll(args: argparse.Namespace) -> int:
poll_interval = _normalize_poll_interval(args.poll_interval)
timeout = _normalize_timeout(args.timeout)
json_out = _normalize_json_out(args.json_out, "poll.json")
client = _create_client()
final_video = _poll_video(
client,
args.id,
poll_interval=poll_interval,
timeout=timeout,
)
_print_json(final_video)
_write_json_out(json_out, final_video)
if args.download:
status = _get_status(final_video) or "unknown"
if status != "completed":
_die(f"Video status is {status}; download is available only after completion.")
variant = _normalize_variant(args.variant)
out_path = _normalize_out_path(args.out, variant)
data = _download_content(client, args.id, variant)
_write_download(data, out_path, force=args.force)
return 0
def _cmd_status(args: argparse.Namespace) -> int:
json_out = _normalize_json_out(args.json_out, "status.json")
client = _create_client()
video = client.videos.retrieve(args.id)
_print_json(video)
_write_json_out(json_out, video)
return 0
def _cmd_list(args: argparse.Namespace) -> int:
if getattr(args, "before", None):
_die("--before is no longer supported by the Videos API docs. Use --after for pagination.")
params: Dict[str, Any] = {
"limit": args.limit,
"order": _normalize_order(args.order),
"after": args.after,
}
params = {k: v for k, v in params.items() if v is not None}
json_out = _normalize_json_out(args.json_out, "list.json")
client = _create_client()
videos = client.videos.list(**params)
_print_json(videos)
_write_json_out(json_out, videos)
return 0
def _cmd_delete(args: argparse.Namespace) -> int:
json_out = _normalize_json_out(args.json_out, "delete.json")
client = _create_client()
result = client.videos.delete(args.id)
_print_json(result)
_write_json_out(json_out, result)
return 0
def _cmd_remix(args: argparse.Namespace) -> int:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
json_out = _normalize_json_out(args.json_out, "remix.json")
_warn("The remix endpoint is deprecated in the latest Sora docs. Prefer the `edit` command for new workflows.")
if args.dry_run:
preview = {"endpoint": f"/v1/videos/{args.id}/remix", "prompt": prompt}
_print_request(preview)
_write_json_out(json_out, {"dry_run": True, "request": preview})
return 0
client = _create_client()
result = client.videos.remix(video_id=args.id, prompt=prompt)
_print_json(result)
_write_json_out(json_out, result)
return 0
def _cmd_download(args: argparse.Namespace) -> int:
variant = _normalize_variant(args.variant)
out_path = _normalize_out_path(args.out, variant)
client = _create_client()
data = _download_content(client, args.id, variant)
_write_download(data, out_path, force=args.force)
return 0
def _cmd_create_character(args: argparse.Namespace) -> int:
json_out = _normalize_json_out(args.json_out, "create-character.json")
if args.dry_run:
preview = {
"endpoint": "/v1/videos/characters",
"name": args.name,
"video": args.video_file,
}
_print_request(preview)
_write_json_out(json_out, {"dry_run": True, "request": preview})
return 0
client = _create_client()
with _open_video_upload(args.video_file, label="Character video") as video_file:
result = _video_post(
client,
"/videos/characters",
{"name": args.name},
files=[("video", video_file)],
)
_print_json(result)
_write_json_out(json_out, result)
return 0
def _cmd_extend(args: argparse.Namespace) -> int:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
seconds = _normalize_seconds(args.seconds)
json_out = _normalize_json_out(args.json_out, "extend.json")
payload = {
"video": {"id": args.id},
"prompt": prompt,
"seconds": seconds,
}
if args.dry_run:
_print_request({"endpoint": "/v1/videos/extensions", **payload})
_write_json_out(
json_out,
{"dry_run": True, "request": {"endpoint": "/v1/videos/extensions", **payload}},
)
return 0
client = _create_client()
result = _video_post(client, "/videos/extensions", payload)
_print_json(result)
_write_json_out(json_out, result)
return 0
def _cmd_edit(args: argparse.Namespace) -> int:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
json_out = _normalize_json_out(args.json_out, "edit.json")
payload: Dict[str, Any] = {"prompt": prompt, "video": {"id": args.id}}
if args.dry_run:
_print_request({"endpoint": "/v1/videos/edits", **payload})
_write_json_out(
json_out,
{"dry_run": True, "request": {"endpoint": "/v1/videos/edits", **payload}},
)
return 0
client = _create_client()
result = _video_post(client, "/videos/edits", payload)
_print_json(result)
_write_json_out(json_out, result)
return 0
class _NullContext:
def __enter__(self):
return None
def __exit__(self, exc_type, exc, tb):
return False
class _SingleFile:
def __init__(self, path: Path):
self._path = path
self._handle = None
def __enter__(self):
self._handle = self._path.open("rb")
return self._handle
def __exit__(self, exc_type, exc, tb):
if self._handle:
try:
self._handle.close()
except Exception:
pass
return False
def _add_prompt_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--prompt")
parser.add_argument("--prompt-file")
parser.add_argument("--augment", dest="augment", action="store_true")
parser.add_argument("--no-augment", dest="augment", action="store_false")
parser.set_defaults(augment=True)
parser.add_argument("--use-case")
parser.add_argument("--scene")
parser.add_argument("--subject")
parser.add_argument("--action")
parser.add_argument("--camera")
parser.add_argument("--style")
parser.add_argument("--lighting")
parser.add_argument("--palette")
parser.add_argument("--audio")
parser.add_argument("--dialogue")
parser.add_argument("--text")
parser.add_argument("--timing")
parser.add_argument("--constraints")
parser.add_argument("--negative")
def _add_create_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--size", default=DEFAULT_SIZE)
parser.add_argument("--seconds", default=DEFAULT_SECONDS)
parser.add_argument("--input-reference")
parser.add_argument("--input-reference-file-id")
parser.add_argument("--input-reference-url")
parser.add_argument("--character-id", action="append", default=[])
parser.add_argument("--dry-run", action="store_true")
_add_prompt_args(parser)
def _add_poll_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL)
parser.add_argument("--timeout", type=float)
def _add_download_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--download", action="store_true")
parser.add_argument("--variant", default=DEFAULT_VARIANT)
parser.add_argument("--out")
parser.add_argument("--force", action="store_true")
def _add_json_out(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--json-out")
def main() -> int:
parser = argparse.ArgumentParser(description="Create and manage videos via the Sora Video API")
subparsers = parser.add_subparsers(dest="command", required=True)
create_parser = subparsers.add_parser("create", help="Create a new video job")
_add_create_args(create_parser)
_add_json_out(create_parser)
create_parser.set_defaults(func=_cmd_create)
create_poll_parser = subparsers.add_parser(
"create-and-poll",
help="Create a job, poll until complete, optionally download",
)
_add_create_args(create_poll_parser)
_add_poll_args(create_poll_parser)
_add_download_args(create_poll_parser)
_add_json_out(create_poll_parser)
create_poll_parser.set_defaults(func=_cmd_create_and_poll)
poll_parser = subparsers.add_parser("poll", help="Poll a job until it completes")
poll_parser.add_argument("--id", required=True)
_add_poll_args(poll_parser)
_add_download_args(poll_parser)
_add_json_out(poll_parser)
poll_parser.set_defaults(func=_cmd_poll)
status_parser = subparsers.add_parser("status", help="Retrieve a job status")
status_parser.add_argument("--id", required=True)
_add_json_out(status_parser)
status_parser.set_defaults(func=_cmd_status)
list_parser = subparsers.add_parser("list", help="List recent video jobs")
list_parser.add_argument("--limit", type=int)
list_parser.add_argument("--order")
list_parser.add_argument("--after")
_add_json_out(list_parser)
list_parser.set_defaults(func=_cmd_list)
delete_parser = subparsers.add_parser("delete", help="Delete a video job")
delete_parser.add_argument("--id", required=True)
_add_json_out(delete_parser)
delete_parser.set_defaults(func=_cmd_delete)
remix_parser = subparsers.add_parser("remix", help="Legacy remix of a completed video job")
remix_parser.add_argument("--id", required=True)
remix_parser.add_argument("--dry-run", action="store_true")
_add_prompt_args(remix_parser)
_add_json_out(remix_parser)
remix_parser.set_defaults(func=_cmd_remix)
download_parser = subparsers.add_parser("download", help="Download video/thumbnail/spritesheet")
download_parser.add_argument("--id", required=True)
download_parser.add_argument("--variant", default=DEFAULT_VARIANT)
download_parser.add_argument("--out")
download_parser.add_argument("--force", action="store_true")
download_parser.set_defaults(func=_cmd_download)
batch_parser = subparsers.add_parser(
"create-batch",
help="Create multiple video jobs locally from JSONL input (not the Batch API)",
)
_add_create_args(batch_parser)
batch_parser.add_argument("--input", required=True, help="Path to JSONL file (one job per line)")
batch_parser.add_argument("--out-dir", required=True)
batch_parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY)
batch_parser.add_argument("--max-attempts", type=int, default=DEFAULT_MAX_ATTEMPTS)
batch_parser.add_argument("--fail-fast", action="store_true")
batch_parser.set_defaults(func=_create_batch)
character_parser = subparsers.add_parser("create-character", help="Create a reusable non-human character from a video")
character_parser.add_argument("--name", required=True)
character_parser.add_argument("--video-file", required=True)
character_parser.add_argument("--dry-run", action="store_true")
_add_json_out(character_parser)
character_parser.set_defaults(func=_cmd_create_character)
extend_parser = subparsers.add_parser("extend", help="Extend a completed video")
extend_parser.add_argument("--id", required=True)
extend_parser.add_argument("--seconds", default=DEFAULT_SECONDS)
extend_parser.add_argument("--dry-run", action="store_true")
_add_prompt_args(extend_parser)
_add_json_out(extend_parser)
extend_parser.set_defaults(func=_cmd_extend)
edit_parser = subparsers.add_parser("edit", help="Edit an existing generated video by ID")
edit_parser.add_argument("--id", required=True, help="Existing generated video ID to edit")
edit_parser.add_argument("--dry-run", action="store_true")
_add_prompt_args(edit_parser)
_add_json_out(edit_parser)
edit_parser.set_defaults(func=_cmd_edit)
args = parser.parse_args()
if getattr(args, "concurrency", 1) < 1 or getattr(args, "concurrency", 1) > 10:
_die("--concurrency must be between 1 and 10")
if getattr(args, "max_attempts", DEFAULT_MAX_ATTEMPTS) < 1 or getattr(args, "max_attempts", DEFAULT_MAX_ATTEMPTS) > 10:
_die("--max-attempts must be between 1 and 10")
dry_run = bool(getattr(args, "dry_run", False))
_ensure_api_key(dry_run)
args.func(args)
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:templates/brand-spots.md
# Brand Spot Templates
Medium-form videos (8-20s) for brand identity, company culture, and brand storytelling. These are the "who we are" videos.
## Brand Essence (12-16s)
Captures the core feeling of the brand.
```
Use case: brand identity
Primary request: visual metaphor for [BRAND VALUE: innovation / craftsmanship / sustainability / connection]
Scene/background: [ENVIRONMENT that evokes brand]: modern architecture / natural landscape / workshop / urban pulse
Subject: abstract or symbolic — light, water, texture, motion
Action: beat 1 (0-4s) establish environment; beat 2 (4-8s) reveal symbolic element; beat 3 (8-12s) element transforms or evolves; beat 4 (12-16s) settle into resolved, balanced state
Camera: cinematic 1280x720, mixed focal lengths, fluid motion
Lighting/mood: [BRAND MOOD: warm and human / cool and precise / natural and honest]
Color palette: [BRAND COLORS], graded for cinematic feel
Constraints: no people; no text; no product; pure brand feeling; 12-16 seconds
Avoid: literal product shots; corporate stock feel; generic imagery
```
### CLI command:
```bash
uv run --with openai python "$SORA_CLI" create-and-poll \
--prompt "Visual metaphor for craftsmanship — hands of light shaping raw material into refined form" \
--use-case "brand identity" \
--scene "warm workshop, golden hour light through dusty windows" \
--camera "cinematic, fluid dolly, shifting focal lengths" \
--lighting "warm, natural, golden" \
--palette "amber, warm grey, cream" \
--seconds 16 \
--download \
--out brand-essence.mp4
```
## Culture Vignette (8-12s)
Abstract representation of company culture.
```
Use case: brand culture
Primary request: abstract representation of [CULTURE THEME: collaboration / creativity / precision / growth]
Scene/background: spaces that suggest human activity without showing people
Subject: objects in motion, workspaces, tools, organic processes
Action: gentle, contemplative movement — things coming together, aligning, growing
Camera: 1280x720, slow tracking shots, observational
Lighting/mood: natural, authentic, unposed
Color palette: [BRAND COLORS], muted, warm
Constraints: 8-12 seconds; no people; no text; workplace adjacent but abstract
Avoid: corporate clichés; sterile offices; handshakes; sticky notes on glass
```
## Origin Story (16-20s)
Visual journey from beginning to present.
```
Use case: brand story
Primary request: visual journey from [ORIGIN: raw material / seed / spark] to [CURRENT STATE: refined product / growing forest / bright creation]
Scene/background: transitions from raw/simple to refined/complex
Subject: transformation of a symbolic element over time
Action: continuous transformation arc — rough to refined, small to expansive, simple to complex
Camera: 1280x720, evolving perspective — starts tight, gradually widens
Lighting/mood: starts moody/raw, evolves to bright/confident
Color palette: evolves from [MUTED] to [VIBRANT BRAND COLORS]
Constraints: 16-20 seconds; single continuous take feel; no people; no text
Avoid: jump cuts; literal timelines; before/after split screen
```
## Values Montage Element (4-8s each)
Individual clips representing each brand value. Designed to be used as standalone elements or edited together.
```
Use case: brand values
Primary request: visual representation of [VALUE: trust / quality / speed / care / innovation]
Scene/background: environment that embodies the value
Subject: symbolic action or object
Action: single clear motion or transformation that represents the value
Camera: 1280x720, consistent style across all values
Lighting/mood: consistent brand-aligned lighting
Color palette: [BRAND COLORS]
Constraints: 4-8 seconds; one value per clip; no text; consistent visual language
Avoid: literal interpretation; cliché symbols (lightbulbs for innovation, etc.)
```
## Usage Notes
- Use `sora-2-pro` for brand spots — quality matters more than speed
- Generate at 1920x1080 for hero brand content (requires `sora-2-pro`)
- Create 2-3 variants of each spot and curate the best
- Brand spots work best with abstract/symbolic imagery, not literal product shots
- Use `--no-augment` when prompts are already carefully structured
- Consider generating a character reference for recurring brand mascots
FILE:templates/launch-teaser.md
# Launch Teaser Templates
Building anticipation for product launches, feature drops, or brand reveals. Designed for "coming soon" energy.
## Countdown Teaser (4-8s)
Quick mystery reveal — perfect for a series of teasers leading up to launch.
```
Use case: launch teaser
Primary request: mysterious partial reveal of [PRODUCT/FEATURE/BRAND]
Scene/background: dark environment, emerging light, sense of anticipation
Subject: partially obscured [PRODUCT] — silhouette, shadow, single detail lit
Action: slow reveal — light gradually illuminates a portion, then cuts before full reveal
Camera: 1280x720, tight crop, slow push-in
Lighting/mood: dramatic, mysterious, building anticipation
Color palette: dark base, [1 BRAND ACCENT COLOR] as reveal light
Constraints: 4-8 seconds; never show the full product; leave viewer wanting more
Avoid: full product reveal; bright environments; casual feel
```
### CLI command:
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Mysterious silhouette of [PRODUCT], single accent light revealing one edge" \
--use-case "launch teaser" \
--scene "pitch dark, single beam of light" \
--camera "tight crop, slow push-in" \
--lighting "dramatic, single accent light" \
--palette "black, [BRAND ACCENT]" \
--seconds 4
```
## "Something's Coming" (8-12s)
Abstract energy building — no product shown at all.
```
Use case: launch teaser
Primary request: abstract energy building toward a moment of [REVEAL/ARRIVAL/TRANSFORMATION]
Scene/background: starts empty/minimal, fills with motion/energy
Subject: particles, light, abstract forms gathering and converging
Action: beat 1 (0-4s) stillness, single element; beat 2 (4-8s) energy gathers, motion builds; beat 3 (8-12s) convergence toward a point, hold at peak tension — don't resolve
Camera: 1280x720, starts wide, slowly tightens as energy builds
Lighting/mood: starts dim, builds to vibrant; tension without release
Color palette: [BRAND COLORS], building intensity
Constraints: 8-12 seconds; NO product; pure anticipation; end at peak tension
Avoid: resolution; reveal; calm endings; literal imagery
```
## Feature Drop (8-16s)
Reveal a specific new feature or capability.
```
Use case: feature announcement
Primary request: dramatic reveal of [FEATURE] capability
Scene/background: clean, focused environment highlighting the feature
Subject: [FEATURE visualization: speed → blur to sharp / scale → small to vast / precision → chaos to order]
Action: problem state → transformation → feature-enabled state
Camera: 1280x720, match motion to feature (fast feature = fast camera)
Lighting/mood: starts constrained, opens up with feature reveal
Color palette: [BRAND COLORS], before=muted / after=vibrant
Constraints: 8-16 seconds; feature must be visually obvious; no text
Avoid: multiple features at once; subtle reveal; anti-climax
```
## Launch Day (12-20s)
The big reveal — full product or brand unveiling.
```
Use case: product launch
Primary request: grand reveal of [PRODUCT/BRAND] — this is the moment
Scene/background: dramatic environment, designed for impact
Subject: [PRODUCT] in its full glory
Action: beat 1 (0-4s) anticipation build, final tension; beat 2 (4-8s) reveal moment — light, motion, arrival; beat 3 (8-16s) product hero moment — beauty shots, angles; beat 4 (16-20s) settle into confident, resolved state
Camera: 1280x720 or 1920x1080 (sora-2-pro), cinematic multi-angle feel
Lighting/mood: dramatic reveal → premium confidence
Color palette: [FULL BRAND PALETTE], peak vibrancy
Constraints: 12-20 seconds; this IS the full reveal; premium quality mandatory
Avoid: anticlimactic reveal; too many elements; rushed pacing
```
## Teaser Series Pattern
For maximum impact, generate a series of teasers leading to launch:
| Timing | Template | Duration | What Shows |
|--------|----------|----------|-----------|
| T-14 days | "Something's Coming" | 8s | Abstract energy, no product |
| T-10 days | Countdown Teaser #1 | 4s | Single material detail |
| T-7 days | Countdown Teaser #2 | 4s | Shape silhouette |
| T-3 days | Countdown Teaser #3 | 4s | Near-reveal, one feature visible |
| T-1 day | Feature Drop | 8s | Key feature demo |
| Launch Day | Launch Day | 16-20s | Full hero reveal |
## Usage Notes
- Teasers work best in series — plan the full arc
- Use `sora-2` for teaser iterations, `sora-2-pro` for final launch day video
- Keep teasers SHORT (4-8s) — social platforms favor brief content
- Generate 5+ variants per teaser, pick the most mysterious
- Never show more than intended — discipline drives anticipation
- Use `extend` to build a teaser into a longer launch day video
FILE:templates/product-demo.md
# Product Demo Templates
Ready-to-use prompt templates for product showcase videos. Adapt the bracketed fields to your specific product.
## Hero Shot (4-8s)
Classic product reveal with premium studio feel.
```
Use case: product demo
Primary request: close-up of [PRODUCT] on [SURFACE: marble pedestal / wooden table / glass shelf]
Scene/background: dark studio cyclorama, subtle haze
Subject: [PRODUCT DESCRIPTION: matte black wireless speaker with fabric texture]
Action: slow [15-30]-degree orbit over [4-8] seconds
Camera: 85mm, shallow depth of field, steady dolly
Lighting/mood: soft key light, gentle rim, premium studio feel
Color palette: [3-5 COLORS: charcoal, slate, warm amber accents]
Constraints: no logos, no text, no people
Avoid: harsh bloom; oversharpening; clutter
```
### CLI command:
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Close-up of a matte black wireless speaker on a marble pedestal" \
--use-case "product demo" \
--scene "dark studio cyclorama, subtle haze" \
--camera "85mm, shallow depth of field, slow 20-degree orbit" \
--lighting "soft key, gentle rim, premium studio feel" \
--palette "charcoal, slate, warm amber" \
--constraints "no logos, no text" \
--seconds 8
```
## Feature Highlight (8-12s)
Show a specific product feature in action.
```
Use case: product feature demo
Primary request: [PRODUCT] demonstrating [FEATURE: wireless charging / auto-adjust / water resistance]
Scene/background: clean workspace, natural light, minimal props
Subject: [PRODUCT] centered on [SURFACE]
Action: beat 1 (0-4s) product at rest, subtle light play; beat 2 (4-8s) [FEATURE ACTIVATION: lid opens / light turns on / water splashes]; beat 3 (8-12s) hold on result
Camera: 50mm, gentle push-in transitioning to locked-off
Lighting/mood: bright, modern, confident
Color palette: [BRAND COLORS], white background
Constraints: single product in frame; feature clearly visible; no people
Avoid: motion blur on feature activation; cluttered background
```
## 360 Product Spin (8-16s)
Full rotation product view.
```
Use case: product showcase
Primary request: full 360-degree rotation of [PRODUCT]
Scene/background: seamless white or gradient backdrop
Subject: [PRODUCT] centered on turntable
Action: smooth continuous rotation, one full turn over [8-16] seconds
Camera: locked-off, eye-level, 85mm, shallow depth of field
Lighting/mood: soft even studio lighting, subtle shadows
Color palette: [PRODUCT COLORS] against white/neutral
Constraints: perfectly centered; consistent lighting throughout rotation; no text
Avoid: wobble; speed variation; shadow changes
```
## Unboxing Sequence (12-20s)
Premium unboxing experience.
```
Use case: product unboxing
Primary request: premium unboxing of [PRODUCT] from [PACKAGING: matte box / eco kraft / magnetic case]
Scene/background: clean surface, warm ambient light
Subject: product packaging transitioning to revealed product
Action: beat 1 (0-4s) box slides into frame; beat 2 (4-8s) lid lifts smoothly; beat 3 (8-12s) product revealed with soft glow; beat 4 (12-16s) product placed on surface
Camera: top-down shifting to 45-degree angle, gentle motion
Lighting/mood: warm, aspirational, premium
Color palette: [BRAND COLORS], neutral backdrop
Constraints: smooth transitions; no visible hands; product pristine
Avoid: fast motion; harsh shadows; cluttered surface
```
## Macro Detail (4-8s)
Extreme close-up highlighting material quality.
```
Use case: product detail
Primary request: macro shot of [DETAIL: brushed aluminum / leather grain / glass surface] on [PRODUCT]
Scene/background: out-of-focus neutral backdrop, soft bokeh
Subject: [SPECIFIC DETAIL AREA] surface texture and finish
Action: slow push-in over [4-8] seconds, subtle light play across surface
Camera: 100mm macro, shallow depth of field, locked tripod
Lighting/mood: directional key, subtle speculars revealing texture
Color palette: [MATERIAL COLORS], soft highlights
Constraints: sharp focus on texture; no text; no logos
Avoid: flicker; unstable reflections; excessive noise
```
## Usage Notes
- Start with 4-8s clips for iteration, then extend successful takes
- Use `sora-2` for rapid iteration, `sora-2-pro` for final hero shots
- Input reference images help lock first-frame composition — use product photos
- For product lines, create character references for mascots/brand elements
- Download assets promptly — URLs expire after ~1 hour
FILE:templates/social-ads.md
# Social Ad Templates
Short-form video templates optimized for social media platforms. All clips designed for 4-8 seconds (ideal for feeds and stories).
## Instagram/TikTok Story Ad (4s, 720x1280)
Vertical format for stories and reels.
```
Use case: social ad
Primary request: [PRODUCT/SERVICE] eye-catching vertical reveal
Scene/background: vibrant gradient or lifestyle setting
Subject: [PRODUCT] or [brand visual element] centered
Action: fast zoom-in or pop-up reveal, hold final frame 1s
Camera: vertical 9:16, dynamic motion, snappy
Lighting/mood: bright, energetic, attention-grabbing
Color palette: [BRAND COLORS], high contrast
Constraints: vertical 720x1280; max 4 seconds; no text (add in post)
Avoid: slow pacing; muted colors; horizontal framing
```
### CLI command:
```bash
uv run --with openai python "$SORA_CLI" create \
--prompt "Eye-catching product reveal of [PRODUCT]" \
--use-case "social ad" \
--scene "vibrant gradient background" \
--camera "dynamic zoom-in, snappy" \
--lighting "bright, energetic" \
--size 720x1280 \
--seconds 4
```
## LinkedIn Professional (8s, 1280x720)
Polished horizontal format for professional feeds.
```
Use case: social ad
Primary request: [PRODUCT/SERVICE] professional showcase
Scene/background: clean office or studio setting, soft bokeh
Subject: [PRODUCT] or [concept visualization]
Action: beat 1 (0-4s) product/concept enters frame; beat 2 (4-8s) settle and subtle motion
Camera: horizontal 16:9, 50mm, steady, slight push
Lighting/mood: professional, warm, trustworthy
Color palette: [BRAND COLORS], muted professional tones
Constraints: horizontal 1280x720; 8 seconds; no text overlay
Avoid: consumer-feel aesthetics; flashy effects; hand-held shake
```
## Feed Scroll-Stopper (4s, 1280x720)
Designed to stop the scroll — high impact first frame.
```
Use case: social ad
Primary request: dramatic [PRODUCT] moment that demands attention
Scene/background: high contrast, clean backdrop
Subject: [PRODUCT] in motion — falling, spinning, catching light
Action: single dramatic motion over 4 seconds, smooth slow-motion feel
Camera: 1280x720, 85mm, locked off, clean composition
Lighting/mood: dramatic, single strong light source, bold shadows
Color palette: [2-3 COLORS] high contrast
Constraints: 4 seconds; first frame must be visually striking; no text
Avoid: busy backgrounds; gentle motion; subtle effects
```
## Carousel Element (4s each, 1280x720)
Individual clips for a multi-slide carousel ad.
```
Use case: social carousel
Primary request: [FEATURE N] of [PRODUCT] — clean isolated showcase
Scene/background: consistent [COLOR] backdrop across all clips
Subject: [PRODUCT] highlighting [SPECIFIC FEATURE]
Action: simple, consistent motion (same style across all carousel clips)
Camera: same lens, same angle, same distance across all clips
Lighting/mood: consistent across all clips — match exactly
Color palette: [SAME PALETTE for all clips]
Constraints: 4 seconds each; visually consistent set; no text
Avoid: varying styles between clips; different backgrounds; inconsistent lighting
```
**Tip:** Generate all carousel clips in one session to maintain consistency. Use the same `--scene`, `--camera`, and `--lighting` flags across all clips.
## Usage Notes
- Always shoot 720x1280 for stories, 1280x720 for feeds
- 4 seconds is the sweet spot for social — shorter is better
- Generate 3-5 variants and A/B test
- Use `sora-2` for rapid iteration, `sora-2-pro` for final picks
- Add text/CTAs in your design tool, not in the video prompt
- Download immediately — URLs expire after ~1 hour
Build premium static landing pages with the Stomme/PolyTrader design system. Glass morphism, CSS custom properties, separated copy, responsive, Cloudflare Pa...
---
name: landing-page-builder
description: Build premium static landing pages with the Stomme/PolyTrader design system. Glass morphism, CSS custom properties, separated copy, responsive, Cloudflare Pages-ready. Use when building a website, landing page, marketing site, or product page. Produces static HTML/CSS/JS with no framework dependencies.
---
# Landing Page Builder
Build premium static landing pages using the proven design system from polytrader.ai.
## Stack
- Static HTML/CSS/JS — no frameworks, no build tools
- CSS custom properties for all theming
- Google Fonts via preconnect
- Cloudflare Pages deployment target
## Procedure
1. **Read content sources** — all copy must come from provided markdown files, never invented
2. **Read the design system reference** — `read references/design-system.md` for the full CSS pattern library
3. **Separate copy from layout** — define ALL text in a `js/copy.js` data file, reference from HTML via `data-copy` attributes or JS injection. This enables i18n later.
4. **Build pages** using the section patterns from the design system
5. **Include**: `_headers` (security headers), `_redirects`, `robots.txt`, `sitemap.xml`, `.gitignore`
6. **Generate validation scripts** — adapt `references/pre-push-check-template.sh` and `references/validate-live-template.js` for the specific site (selectors, locales, CSS vars). Place in `scripts/`. Set up `.githooks/pre-push`. Wire into CI workflow.
7. **Test**: open in browser at desktop AND mobile viewports. Run `bash scripts/pre-push-check.sh`. Verify theme toggle (3 full cycles), lang switcher (all locales), contrast on all interactive elements.
8. **Git init + commit** (hooks path set to `.githooks/`)
9. **Write BUILD-NOTES.md** with Cloudflare Pages deployment instructions
## Design System Principles
The reference file has the full implementation. Key principles:
- **Dual theme** — dark premium default + light mode. Auto-detects `prefers-color-scheme`, user toggle in nav, `localStorage` persistence, inline `<head>` script prevents flash
- **Glass morphism** — `.glass` cards with backdrop-filter, subtle borders, inset shadows — adapts to both themes
- **CSS custom properties** — every color, spacing value, and font through variables. Dark values in `:root`, light overrides in `[data-theme="light"]`
- **Gold/brand accent** — gradient CTAs, accent moments, section kickers. Slightly deepened in light mode for contrast
- **Ambient backgrounds** — layered radial-gradients for depth, NOT solid colors — both themes use them
- **Typography** — Google Fonts (Plus Jakarta Sans or similar geometric sans), tight tracking on headings (-0.035em), generous body line-height (1.75)
- **Interactions** — subtle translateY lifts on hover, gradient buttons with glow shadows
- **Theme toggle** — sun/moon SVG icons in nav, `localStorage` key for persistence, OS change listener
## Section Patterns (in order)
1. **Sticky nav** — frosted glass, pill shape or clean bar, brand + links + theme toggle + CTA
2. **Hero** — large headline, subheadline, dual CTAs (gradient primary + outline secondary), trust signals in glass card grid below
3. **Value proposition** — narrative text section explaining the core differentiator
4. **How it works** — numbered steps (01, 02, 03...) in glass cards, 2-column layout
5. **Features** — alternating layout (text left/visual right, then swap), glass cards
6. **Pricing** — tier cards with ring highlight on featured plan, checklist items with check icons
7. **FAQ** — 2-column glass card grid, question + answer
8. **Bottom CTA** — full-width banner, headline + CTA + supporting line
9. **Footer** — minimal, border-top, brand + links + legal
## Theme Architecture
Every page must include:
1. **Inline `<head>` script** (blocking, before CSS loads) — reads `localStorage` key, falls back to `prefers-color-scheme`, sets `data-theme="light"` on `<html>` if light
2. **`:root`** — dark theme variables (default)
3. **`[data-theme="light"]`** — light theme variable overrides
4. **`--body-bg-gradient`** variable — ambient background through a custom property so it switches with theme
5. **Theme toggle button** in nav with sun/moon SVG icons, visibility driven by CSS `--theme-icon-sun` / `--theme-icon-moon` variables
6. **JS in main.js** — `initTheme()` function: toggle click handler, `localStorage.setItem`, OS `change` listener (respects manual override)
7. **Smooth transitions** — 300ms ease on `color`, `background`, `border-color`, `box-shadow` for themed elements
8. **`--btn-primary-text`** — button text color variable (dark on dark theme where bg is gold, white on light theme)
## File Structure
```
site-root/
├── index.html
├── pricing.html
├── privacy.html
├── terms.html
├── 404.html
├── css/
│ └── style.css # Full design system + page styles (dark + light themes)
├── js/
│ ├── copy.js # ALL text content as exportable object
│ └── main.js # Nav toggle, smooth scroll, theme toggle, minor interactions
├── img/
│ ├── favicon.svg
│ └── og-placeholder.png
├── _headers # Cloudflare security headers
├── _redirects # Cloudflare redirects
├── robots.txt
├── sitemap.xml
├── .gitignore
└── BUILD-NOTES.md
```
## Post-Build Validation (MANDATORY)
Every build **must** include a `scripts/` directory with two validation scripts. These are not optional — they are part of the deliverable, like _headers or sitemap.xml.
### 1. `scripts/pre-push-check.sh` — Static pre-push gate
Runs before every `git push` (via `.githooks/pre-push`). Checks:
- All `<script src>` and `<link href>` tags have cache-bust version params (`?v=HASH`)
- No hardcoded hex colors in style.css (all colors via CSS custom properties)
- copy.js and main.js parse without syntax errors
- `applyTheme()` is called on DOM load (not just on toggle click)
- CTA buttons have explicit color override (prevents inheritance from ancestor selectors like `.nav-links a`)
- No `removeAttribute('data-theme')` in any HTML file (must always set theme explicitly)
- Exit 1 on any failure — blocks the push
### 2. `scripts/validate-live.js` — Post-deploy browser validation
Runs in CI after every Cloudflare Pages deploy, using Playwright. Tests at **both** desktop (1440px) and mobile (375px) viewports:
- CSS custom properties resolve to non-empty values
- Contrast ratios on all interactive elements meet WCAG AA (4.5:1 normal text, 3.0:1 large)
- Theme toggle: 3 full cycles (6 clicks), verifies alternation and `localStorage` sync
- Language switcher: all locales produce non-empty hero text and correct `localStorage` value
- All `<script>` and `<link>` tags have version params
- No broken internal links (`href="#"`, empty, or undefined)
- Meta tags present: title, description, og:title
### 3. Git hook setup
```bash
mkdir -p .githooks
echo '#!/bin/bash' > .githooks/pre-push
echo 'bash "$(git rev-parse --show-toplevel)/scripts/pre-push-check.sh"' >> .githooks/pre-push
chmod +x .githooks/pre-push
git config core.hooksPath .githooks
```
### 4. CI workflow must include validation job
The GitHub Actions workflow must have a `validate` job that runs **after** the deploy job:
```yaml
validate:
needs: deploy
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- name: Install Playwright
run: npx playwright install chromium --with-deps
- name: Wait for deploy propagation
run: sleep 30
- name: Run post-deploy validation
run: node scripts/validate-live.js https://$DOMAIN
```
**Why this exists:** We shipped a grey-on-red CTA button and a broken theme toggle to production because we relied on visual review alone. Computed style checks catch what eyes miss. Mobile Safari caching broke deploys because we tested desktop only. These scripts encode every lesson into automated gates.
## Constraints
- No Tailwind, no Bootstrap, no React — hand-written CSS
- No external CDN for JS (except Google Fonts CSS)
- No analytics scripts (added separately later)
- No invented features — only what's in the content source files
- All file permissions 0o644 (static files, not secrets)
- Must pass Lighthouse performance >90
FILE:references/design-system.md
# Design System Reference — Premium Landing Pages
Extracted from the polytrader.ai production landing page. Adapt colors to brand; keep the structural patterns.
Supports dual light/dark themes with auto browser detection, manual toggle, and localStorage persistence.
## CSS Custom Properties
Every value goes through a custom property. Never hardcode colors or spacing.
Dark theme in `:root` (default). Light theme overrides in `[data-theme="light"]`.
```css
:root {
/* Background layers — dark premium */
--bg-primary: #0a0a0c;
--bg-secondary: #121216;
--bg-tertiary: #1a1a1f;
--bg-card: rgba(255, 255, 255, 0.04);
--bg-card-hover: rgba(255, 255, 255, 0.08);
--bg-elevated: rgba(255, 255, 255, 0.06);
/* Borders — subtle, rgba-based */
--border-subtle: rgba(255, 255, 255, 0.08);
--border-medium: rgba(255, 255, 255, 0.12);
--border-focus: rgba(var(--accent-rgb), 0.4);
/* Glass effect */
--glass-bg: rgba(15, 16, 18, 0.8);
--glass-border: rgba(255, 255, 255, 0.06);
/* Text — high contrast on dark */
--text-primary: #fafafa;
--text-secondary: #a0a0a8;
--text-muted: #606068;
/* Accent — replace with brand color */
--accent: #e4b45c; /* PolyTrader: gold. Stomme: use brand amber/gold */
--accent-bright: #f0c96e;
--accent-soft: rgba(228, 180, 92, 0.12);
--accent-glow: rgba(228, 180, 92, 0.2);
--accent-rgb: 228, 180, 92; /* For rgba() usage */
/* Button text — dark on gold gradient button in dark mode */
--btn-primary-text: #0a0a0c;
/* Semantic */
--green: #10b981;
--red: #f43f5e;
--blue: #3b82f6;
/* Gradients */
--gradient-accent: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent) 50%, color-mix(in srgb, var(--accent), #000 15%) 100%);
--gradient-card: linear-gradient(145deg, rgba(255,255,255,0.035) 0%, rgba(255,255,255,0.01) 100%);
--gradient-hero-ambient: radial-gradient(ellipse 60% 50% at 50% 0%, var(--accent-soft) 0%, transparent 60%);
/* Body ambient background — through variable so it switches with theme */
--body-bg-gradient:
radial-gradient(circle at top left, var(--accent-soft), transparent 24rem),
radial-gradient(circle at 82% 16%, rgba(var(--accent-rgb), 0.08), transparent 22rem),
linear-gradient(180deg, var(--bg-secondary), var(--bg-primary));
/* Typography */
--font-heading: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
--font-body: 'Plus Jakarta Sans', 'SF Pro Text', system-ui, sans-serif;
--font-mono: 'DM Mono', 'JetBrains Mono', monospace;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
--space-4xl: 96px;
/* Radius */
--radius-sm: 10px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-full: 9999px;
/* Shadows */
--shadow-card: 0 24px 64px rgba(0, 0, 0, 0.22);
--shadow-button: 0 18px 50px var(--accent-glow);
/* Max widths */
--max-width: 1160px;
--max-width-narrow: 720px;
/* Theme toggle icon visibility */
--theme-icon-sun: block;
--theme-icon-moon: none;
color-scheme: dark;
}
```
## Light Theme Overrides
Override only what changes — structure, spacing, radius, fonts stay the same.
Light mode: warm birch/parchment bg, deep navy text, slightly deepened accent for contrast.
```css
[data-theme="light"] {
--bg-primary: #f7f3ed;
--bg-secondary: #efe9e0;
--bg-tertiary: #e6dfd5;
--bg-card: rgba(0, 0, 0, 0.03);
--bg-card-hover: rgba(0, 0, 0, 0.06);
--bg-elevated: rgba(0, 0, 0, 0.04);
--border-subtle: rgba(0, 0, 0, 0.08);
--border-medium: rgba(0, 0, 0, 0.12);
--border-focus: rgba(var(--accent-rgb), 0.35);
--glass-bg: rgba(247, 243, 237, 0.85);
--glass-border: rgba(0, 0, 0, 0.06);
--text-primary: #0d1b2a;
--text-secondary: #4a4540;
--text-muted: #8a8580;
/* Accent deepened for readability on light bg */
--accent: #b8872e;
--accent-bright: #d4a24c;
--accent-soft: rgba(184, 135, 46, 0.1);
--accent-glow: rgba(184, 135, 46, 0.15);
--accent-rgb: 184, 135, 46;
/* White text on gold gradient button in light mode */
--btn-primary-text: #ffffff;
--gradient-accent: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent) 50%, color-mix(in srgb, var(--accent), #000 10%) 100%);
--gradient-card: linear-gradient(145deg, rgba(0,0,0,0.02) 0%, rgba(0,0,0,0.005) 100%);
--gradient-hero-ambient: radial-gradient(ellipse 60% 50% at 50% 0%, var(--accent-soft) 0%, transparent 60%);
--body-bg-gradient:
radial-gradient(circle at top left, var(--accent-soft), transparent 24rem),
radial-gradient(circle at 82% 16%, rgba(var(--accent-rgb), 0.05), transparent 22rem),
linear-gradient(180deg, var(--bg-secondary), var(--bg-primary));
--shadow-card: 0 24px 64px rgba(0, 0, 0, 0.06);
--shadow-button: 0 18px 50px var(--accent-glow);
--theme-icon-sun: none;
--theme-icon-moon: block;
color-scheme: light;
}
```
## Glass Morphism
The signature component. Used for cards, nav, feature boxes.
```css
.glass {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)),
var(--glass-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.06),
var(--shadow-card);
}
```
## Premium Panel (Lux Panel)
Elevated glass with accent gradient glow. Used for featured pricing, hero pricing card.
```css
.lux-panel {
position: relative;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015)),
var(--glass-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(var(--accent-rgb), 0.18);
border-radius: var(--radius-lg);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.06),
0 0 80px -20px var(--accent-soft),
var(--shadow-card);
overflow: hidden;
}
.lux-panel::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.08) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
```
## Ambient Page Background
Uses `--body-bg-gradient` variable so it switches automatically with theme.
```css
body {
background: var(--body-bg-gradient);
background-attachment: fixed;
color: var(--text-primary);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
transition: background 300ms ease, color 300ms ease;
}
```
## Theme Toggle Button
Sun/moon toggle in the nav bar. Icon visibility driven by CSS variables — no JS class toggling needed.
```css
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
color: var(--text-secondary);
cursor: pointer;
transition: color 200ms ease, background 200ms ease, border-color 200ms ease;
flex-shrink: 0;
}
.theme-toggle:hover {
color: var(--text-primary);
background: var(--bg-card-hover);
border-color: var(--border-medium);
}
.theme-toggle svg { width: 18px; height: 18px; }
.theme-toggle .icon-sun { display: var(--theme-icon-sun); }
.theme-toggle .icon-moon { display: var(--theme-icon-moon); }
```
### HTML (in nav, before hamburger toggle)
```html
<button class="theme-toggle" aria-label="Toggle light/dark theme" title="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
</button>
```
## Flash Prevention
Add this blocking inline script inside `<head>`, before CSS loads. Reads stored preference or detects OS setting. Prevents the wrong theme from rendering even for one frame.
```html
<script>
(function(){
var t = localStorage.getItem('stomme-theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
})();
</script>
```
Replace `'stomme-theme'` with an appropriate brand-specific key (e.g. `'mysite-theme'`).
## Theme Toggle JS
Add to `main.js`:
```js
function initTheme() {
const toggle = document.querySelector('.theme-toggle');
if (!toggle) return;
const KEY = 'stomme-theme'; // match the key in the head script
function getEffectiveTheme() {
const stored = localStorage.getItem(KEY);
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyTheme(theme) {
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
toggle.addEventListener('click', () => {
const next = getEffectiveTheme() === 'dark' ? 'light' : 'dark';
localStorage.setItem(KEY, next);
applyTheme(next);
});
// Respect OS changes when user hasn't manually set preference
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => {
if (!localStorage.getItem(KEY)) {
applyTheme(e.matches ? 'light' : 'dark');
}
});
}
```
Call `initTheme()` in the DOMContentLoaded handler.
## Smooth Theme Transitions
Add transitions on key themed elements so the switch isn't jarring:
```css
.glass,
.lux-panel,
.nav-bar,
h1, h2, h3, h4, p, a, span,
.section-kicker,
.btn-secondary {
transition: color 300ms ease, background 300ms ease, border-color 300ms ease, box-shadow 300ms ease;
}
```
## Typography
```css
h1, h2, h3, h4 {
font-family: var(--font-heading);
font-weight: 700;
color: var(--text-primary);
}
h1 {
font-size: clamp(2.2rem, 9.4vw, 4.1rem);
line-height: 1.04;
letter-spacing: -0.055em;
}
h2 {
font-size: clamp(2rem, 5vw, 2.55rem);
line-height: 1.12;
letter-spacing: -0.035em;
}
h3 {
font-size: clamp(1.15rem, 2.5vw, 1.45rem);
line-height: 1.16;
letter-spacing: -0.025em;
}
p {
color: var(--text-secondary);
line-height: 1.75;
max-width: 65ch;
}
/* Section kicker — small uppercase label above headings */
.section-kicker {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.6875rem; /* 11px */
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
opacity: 0.8;
}
```
## Buttons
```css
/* Primary — gradient with glow */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0.875rem 1.75rem;
font-family: var(--font-heading);
font-size: 0.875rem;
font-weight: 700;
color: var(--btn-primary-text);
background: var(--gradient-accent);
border: none;
border-radius: var(--radius-full);
box-shadow: var(--shadow-button);
cursor: pointer;
transition: transform 200ms ease;
text-decoration: none;
}
.btn-primary:hover {
transform: translateY(-2px);
}
/* Secondary outline */
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0.875rem 1.5rem;
font-family: var(--font-heading);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
background: transparent;
border: 2px solid var(--border-medium);
border-radius: var(--radius-full);
cursor: pointer;
transition: background-color 200ms ease, border-color 200ms ease;
text-decoration: none;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
border-color: var(--text-primary);
}
```
## Sticky Nav
Includes theme toggle button between links and hamburger.
```css
nav {
position: sticky;
top: 0;
z-index: 30;
margin-bottom: var(--space-lg);
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
max-width: var(--max-width);
margin: 0 auto;
padding: 0.75rem 1rem;
border-radius: var(--radius-full);
border: 1px solid var(--border-subtle);
background: var(--glass-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
}
.nav-brand {
font-family: var(--font-heading);
font-weight: 700;
font-size: 1rem;
color: var(--text-primary);
letter-spacing: -0.03em;
}
.nav-links a {
font-size: 0.875rem;
color: var(--text-secondary);
text-decoration: none;
transition: color 200ms ease;
}
.nav-links a:hover {
color: var(--text-primary);
}
```
### Nav HTML structure
```html
<nav role="navigation" aria-label="Main navigation">
<div class="nav-bar">
<a href="/" class="nav-brand">brand</a>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="/pricing.html">Pricing</a></li>
<li><a href="..." class="btn-primary nav-cta">Get started</a></li>
</ul>
<!-- Theme toggle: place BEFORE hamburger toggle -->
<button class="theme-toggle" aria-label="Toggle theme">...</button>
<button class="nav-toggle" aria-label="Toggle menu">...</button>
</div>
</nav>
```
## Section Layout Patterns
### Hero
- Full-width, centered text
- h1 with tight tracking, p with max-width 44rem
- Two CTAs side by side (primary gradient + secondary outline)
- Below: glass card grid with 3 trust/value signals (icon + title + desc)
### How It Works
- Section kicker + h2 + intro paragraph
- 2-column grid: left has numbered glass cards (01, 02...), right has lux-panel feature highlight
- Number badges: 44px circles, bg-elevated, bold text
### Pricing
- Section kicker + h2 + intro
- 3-column grid (or 2 + sidebar): tier cards as glass panels
- Featured tier: lux-panel with ring-2 accent highlight
- Checklist items with ✓ icon in accent-green
### FAQ
- Section kicker + h2
- 2-column grid of glass cards
- h3 question + p answer per card
### Footer
- border-top separator
- 2-column: brand + tagline left, nav links right
- Bottom row: legal entity + privacy/terms links
- Minimal, no decoration
## Interactions
```css
/* Card hover lift */
.glass:hover,
.card:hover {
transform: translateY(-2px);
transition: transform 200ms ease;
}
/* Selected state — pricing */
.tier-card.selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
```
## Responsive Breakpoints
```css
/* Tablet */
@media (max-width: 1024px) {
.grid-3 { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: 1fr; }
}
/* Mobile */
@media (max-width: 640px) {
h1 { font-size: 2.2rem; }
.nav-links { display: none; }
/* Show hamburger */
}
```
## Separated Copy Pattern
All text content lives in js/copy.js:
```js
const COPY = {
nav: { brand: 'stomme.ai', features: 'Features', pricing: 'Pricing' },
hero: {
headline: 'Your personal AI agent.\nOn your Mac. Without the complexity.',
subtitle: '...',
ctaPrimary: 'Get started — 50% off your first month',
ctaSecondary: 'See how it works',
},
// ... all sections
};
export default COPY;
```
Then in HTML, reference via data attributes or JS hydration:
```html
<h1 data-copy="hero.headline"></h1>
<script type="module">
import COPY from '/js/copy.js';
document.querySelectorAll('[data-copy]').forEach(el => {
const keys = el.dataset.copy.split('.');
let val = COPY;
for (const k of keys) val = val[k];
el.textContent = val;
});
</script>
```
## Google Fonts Loading
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
```
## Cloudflare Pages Files
### _headers
```
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
/css/*
Cache-Control: public, max-age=31536000, immutable
/js/*
Cache-Control: public, max-age=31536000, immutable
/img/*
Cache-Control: public, max-age=31536000, immutable
```
### _redirects
```
/home / 301
/index / 301
```
FILE:references/pre-push-check-template.sh
#!/bin/bash
# Pre-push static checks for stomme-website
# Run before every push: bash scripts/pre-push-check.sh
# Exit 1 = issues found, do not push
set -e
FAIL=0
cd "$(dirname "$0")/.."
echo "── Pre-push checks ──"
# 1. All HTML files have cache-bust versions on script/css refs
echo ""
echo "1. Cache-bust versions on assets..."
for f in *.html; do
# Check script tags
if grep -q '<script.*src=.*\.js"' "$f" 2>/dev/null; then
if ! grep -q '<script.*src=.*\.js?v=' "$f"; then
echo " ❌ $f has unversioned script tag"
FAIL=1
fi
fi
# Check CSS links
if grep -q '<link.*href=.*\.css"' "$f" 2>/dev/null; then
if ! grep -q '<link.*href=.*\.css?v=' "$f"; then
echo " ❌ $f has unversioned CSS link"
FAIL=1
fi
fi
done
[ $FAIL -eq 0 ] && echo " ✅ All assets versioned"
# 2. No hardcoded hex colors in style.css (should use brand tokens)
echo ""
echo "2. No hardcoded colors in style.css..."
HARDCODED=$(grep -En '#[0-9a-fA-F]{3,8}[^a-zA-Z0-9]' css/style.css 2>/dev/null | grep -v '^\s*/\*' | grep -v 'var(' | grep -v '^\s*\*' || true)
if [ -n "$HARDCODED" ]; then
COUNT=$(echo "$HARDCODED" | wc -l | tr -d ' ')
echo " ⚠️ $COUNT lines with hardcoded hex in style.css (check if intentional)"
echo "$HARDCODED" | head -5 | sed 's/^/ /'
else
echo " ✅ No hardcoded hex colors"
fi
# 3. copy.js syntax check
echo ""
echo "3. copy.js syntax..."
if node -e "require('./js/copy.js')" 2>/dev/null || node --check js/copy.js 2>/dev/null; then
echo " ✅ copy.js parses OK"
else
# ES module — try dynamic import
if node -e "import('./js/copy.js').catch(()=>{})" 2>/dev/null; then
echo " ✅ copy.js ES module OK"
else
echo " ⚠️ copy.js syntax could not be validated (ES module)"
fi
fi
# 4. main.js syntax check
echo ""
echo "4. main.js syntax..."
node -e "import('./js/main.js').catch(()=>{})" 2>/dev/null && echo " ✅ main.js OK" || echo " ⚠️ main.js could not be validated"
# 5. Verify theme toggle function exists and calls applyTheme on load
echo ""
echo "5. Theme toggle safety net..."
if grep -q 'applyTheme(getEffectiveTheme())' js/main.js; then
echo " ✅ applyTheme called on load"
else
echo " ❌ applyTheme NOT called on load — theme toggle may break"
FAIL=1
fi
# 6. Nav CTA has color override
echo ""
echo "6. Nav CTA contrast override..."
if grep -q 'nav-cta' css/style.css && grep -A3 'nav-cta' css/style.css | grep -q 'color.*!important\|color.*var(--color-white)'; then
echo " ✅ Nav CTA has explicit color"
else
echo " ❌ Nav CTA missing color override — may inherit grey from nav-links"
FAIL=1
fi
# 7. All HTML inline scripts set data-theme explicitly (no removeAttribute)
echo ""
echo "7. Inline script theme handling..."
REMOVE_ATTR=$(grep -l "removeAttribute.*data-theme" *.html 2>/dev/null || true)
if [ -n "$REMOVE_ATTR" ]; then
echo " ❌ Found removeAttribute('data-theme') in: $REMOVE_ATTR"
echo " Must always set data-theme explicitly (light or dark)"
FAIL=1
else
echo " ✅ No removeAttribute('data-theme') in HTML files"
fi
echo ""
echo "════════════════════════════════"
if [ $FAIL -ne 0 ]; then
echo "⛔ FAIL — fix issues before pushing"
exit 1
else
echo "✅ All pre-push checks passed"
exit 0
fi
FILE:references/validate-live-template.js
#!/usr/bin/env node
/**
* validate-live.js — Automated post-deploy validation for stomme.ai
* Uses Playwright to test in real browser contexts.
*
* Usage: node scripts/validate-live.js [url]
* Default: https://stomme.ai
* Exit 0 = pass, Exit 1 = failures
*/
const { chromium } = require('playwright');
const SITE = process.argv[2] || 'https://stomme.ai';
const PAGES = ['/', '/pricing.html'];
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'mobile-safari', width: 375, height: 812, isMobile: true, hasTouch: true },
];
let pass = 0, fail = 0;
function ok(test, detail = '') {
pass++;
console.log(` ✅ testdetail ? ` — ${detail` : ''}`);
}
function bad(test, detail = '') {
fail++;
console.error(` ❌ testdetail ? ` — ${detail` : ''}`);
}
function luminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(rgb1, rgb2) {
const l1 = luminance(...rgb1);
const l2 = luminance(...rgb2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function parseRgb(str) {
if (!str) return null;
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null;
}
async function run() {
const browser = await chromium.launch({ headless: true });
for (const vp of VIEWPORTS) {
console.log(`\n── vp.name (vp.widthxvp.height) ──`);
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
isMobile: vp.isMobile || false,
hasTouch: vp.hasTouch || false,
});
for (const path of PAGES) {
const url = SITE + path;
console.log(`\n Page: url`);
const page = await context.newPage();
// Clear storage for clean state
await page.goto(url, { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// ── 1. CSS custom properties resolve ──────────────────────────
const vars = await page.evaluate(() => {
const root = getComputedStyle(document.documentElement);
return ['--color-accent', '--color-text', '--color-bg', '--color-white',
'--color-text-secondary', '--color-border', '--font-heading', '--font-body'
].map(v => ({ name: v, val: root.getPropertyValue(v).trim() }));
});
for (const { name, val } of vars) {
if (val && val !== '') ok(`CSS var name`, val.substring(0, 30));
else bad(`CSS var name`, 'EMPTY or undefined');
}
// ── 2. Interactive element contrast ───────────────────────────
const elements = await page.evaluate(() => {
const sels = [
{ sel: '.nav-cta', label: 'Nav CTA button' },
{ sel: '.nav-links a', label: 'Nav link' },
{ sel: '.btn-primary', label: 'Primary button (body)' },
];
return sels.map(({ sel, label }) => {
const el = document.querySelector(sel);
if (!el) return { label, error: 'not found' };
const s = getComputedStyle(el);
return { label, color: s.color, bg: s.backgroundColor, fontSize: parseFloat(s.fontSize) };
});
});
for (const el of elements) {
if (el.error) { bad(`el.label contrast`, el.error); continue; }
const fg = parseRgb(el.color);
const bg = parseRgb(el.bg);
if (!fg || !bg) { bad(`el.label contrast`, `unparseable: el.color / el.bg`); continue; }
const ratio = contrastRatio(fg, bg);
const threshold = el.fontSize >= 18.66 ? 3.0 : 4.5;
if (ratio >= threshold) ok(`el.label contrast`, `ratio.toFixed(2):1 (need threshold:1)`);
else bad(`el.label contrast`, `ratio.toFixed(2):1 — FAILS WCAG AA (need threshold:1)`);
}
// ── 3. Theme toggle — 3 full cycles ───────────────────────────
if (path === '/') {
const toggleResults = await page.evaluate(async () => {
const toggle = document.querySelector('.theme-toggle');
if (!toggle) return { error: 'no toggle' };
const results = [];
for (let i = 0; i < 6; i++) {
toggle.click();
await new Promise(r => setTimeout(r, 150));
results.push({
click: i + 1,
theme: document.documentElement.getAttribute('data-theme'),
bg: getComputedStyle(document.body).backgroundColor,
stored: localStorage.getItem('stomme-theme'),
});
}
return { results };
});
if (toggleResults.error) {
bad('Theme toggle', toggleResults.error);
} else {
const cycles = toggleResults.results;
let toggleOk = true;
// Verify alternating themes
for (let i = 1; i < cycles.length; i++) {
if (cycles[i].theme === cycles[i - 1].theme) {
bad('Theme toggle cycle', `Click i + 1 did not change theme (stuck on cycles[i].theme)`);
toggleOk = false;
break;
}
}
// Verify localStorage matches attribute
for (const c of cycles) {
if (c.theme !== c.stored) {
bad('Theme toggle storage', `data-theme=c.theme but localStorage=c.stored`);
toggleOk = false;
break;
}
}
if (toggleOk) ok('Theme toggle — 3 full cycles', 'alternates correctly, localStorage synced');
}
}
// ── 4. Language switcher — all 3 locales ──────────────────────
if (path === '/') {
const langResults = await page.evaluate(async () => {
const results = [];
for (const lang of ['sv', 'de', 'en']) {
const btn = document.querySelector(`.lang-btn[data-lang="lang"]`);
if (!btn) { results.push({ lang, error: 'button missing' }); continue; }
btn.click();
await new Promise(r => setTimeout(r, 300));
const hero = document.querySelector('[data-copy="heroTitle"]');
results.push({
lang,
heroText: hero?.textContent?.substring(0, 50) || 'EMPTY',
stored: localStorage.getItem('stomme-lang'),
htmlLang: document.documentElement.lang,
});
}
return results;
});
for (const r of langResults) {
if (r.error) { bad(`Lang r.lang`, r.error); continue; }
if (r.heroText === 'EMPTY') bad(`Lang r.lang hero text`, 'empty after switch');
else if (r.stored === r.lang) ok(`Lang r.lang`, `"r.heroText"`);
else bad(`Lang r.lang storage`, `expected r.lang, got r.stored`);
}
}
// ── 5. Cache-bust version params on assets ────────────────────
const assets = await page.evaluate(() => {
return [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')].map(el => ({
tag: el.tagName,
src: el.src || el.href,
hasVersion: /\?v=/.test(el.src || el.href),
}));
});
const unversioned = assets.filter(a => !a.hasVersion && !a.src.includes('cdn'));
if (unversioned.length === 0) ok('Cache-bust hashes', `all assets.length assets versioned`);
else bad('Cache-bust hashes', `unversioned.length unversioned: unversioned.map(a => a.src.split('/').pop()).join(', ')`);
// ── 6. No broken internal links ───────────────────────────────
const brokenLinks = await page.evaluate(() => {
return [...document.querySelectorAll('a[href]')]
.filter(a => {
const h = a.getAttribute('href');
return !h || h === '#' || h === 'undefined' || h === 'null';
})
.map(a => ({ text: a.textContent.trim().substring(0, 30), href: a.getAttribute('href') }));
});
if (brokenLinks.length === 0) ok('Internal links', 'no broken hrefs');
else bad('Internal links', `brokenLinks.length broken: brokenLinks.map(l => l.text).join(', ')`);
// ── 7. Meta tags ──────────────────────────────────────────────
const meta = await page.evaluate(() => ({
title: document.title,
desc: document.querySelector('meta[name="description"]')?.content,
ogTitle: document.querySelector('meta[property="og:title"]')?.content,
}));
if (meta.title) ok('Page title', meta.title.substring(0, 40));
else bad('Page title', 'missing');
if (meta.desc) ok('Meta description', 'present');
else bad('Meta description', 'missing');
await page.close();
}
await context.close();
}
await browser.close();
// ── Summary ─────────────────────────────────────────────────────────────
console.log('\n════════════════════════════════════════');
console.log(` PASS: pass FAIL: fail`);
console.log('════════════════════════════════════════');
if (fail > 0) {
console.error('\n⛔ Validation FAILED — deploy should be investigated');
process.exit(1);
} else {
console.log('\n✅ All checks passed');
process.exit(0);
}
}
run().catch(err => {
console.error('Validation script crashed:', err);
process.exit(1);
});