Commit ·
2f9de1b
1
Parent(s): fae74e0
- .gitignore +7 -0
- FLOW.md +71 -0
- backend/api/__init__.py +1 -0
- backend/api/gpt_image_frames.py +293 -0
- backend/api/image_service.py +126 -0
- backend/api/scraper.py +175 -0
- backend/api/scraper_routes.py +38 -0
- backend/api/seedance_generation.py +584 -0
- backend/api/showcase_prompts.py +1223 -0
- backend/api/video_export.py +250 -0
- backend/api/video_generation.py +761 -0
- backend/main.py +103 -0
- backend/requirements.txt +9 -0
- backend/utils/__init__.py +1 -0
- backend/utils/env_load.py +22 -0
- backend/utils/image_processor.py +98 -0
- backend/utils/public_url.py +16 -0
- backend/utils/storage.py +41 -0
- frontend/index.html +15 -0
- frontend/package-lock.json +2612 -0
- frontend/package.json +26 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +108 -0
- frontend/src/api.ts +691 -0
- frontend/src/components/ShowcaseFlow.tsx +1468 -0
- frontend/src/index.css +212 -0
- frontend/src/main.tsx +10 -0
- frontend/src/types.ts +78 -0
- frontend/src/vite-env.d.ts +11 -0
- frontend/tailwind.config.js +45 -0
- frontend/tsconfig.json +23 -0
- frontend/vite.config.ts +38 -0
- scripts/run-dev.sh +8 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backend/.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env.local
|
| 5 |
+
frontend/node_modules/
|
| 6 |
+
frontend/dist/
|
| 7 |
+
.env*
|
FLOW.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cinematic product showcase flow
|
| 2 |
+
|
| 3 |
+
This repo is a focused slice of a **python-backend**-style stack (same Veo, upload, and merge patterns): **shot planning → KIE Veo segments → FFmpeg merge**.
|
| 4 |
+
|
| 5 |
+
## End-to-end
|
| 6 |
+
|
| 7 |
+
```mermaid
|
| 8 |
+
flowchart LR
|
| 9 |
+
A[Product brief + hero image] --> B["POST /api/showcase/plan-stream"]
|
| 10 |
+
B --> C[Segments JSON compatible with Veo]
|
| 11 |
+
C --> D["POST /api/veo/generate per shot"]
|
| 12 |
+
D --> E[SSE /api/veo/events]
|
| 13 |
+
E --> F["GET /api/veo/download per clip"]
|
| 14 |
+
F --> G["POST /api/export/merge"]
|
| 15 |
+
G --> H[Master MP4]
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
1. **Brief** — Product name, tagline, mood, optional features, hero image, shot count (3–6), segment length (4/6/8s), aspect ratio, seed, voice style.
|
| 19 |
+
2. **Plan** — NDJSON stream builds a `SegmentsPayload` (same `VeoSegment` shape as the reference app). Uses **OpenAI** when `OPENAI_API_KEY` is set; otherwise a **built-in cinematic template**.
|
| 20 |
+
3. **Render** — For each segment, the UI calls KIE **image-to-video** with the segment object as the structured prompt and the **hosted** hero image URL (see `/api/upload-image`).
|
| 21 |
+
4. **Stitch** — Clips are merged with **`/api/export/merge`** (requires **ffmpeg** and **ffprobe** on the server).
|
| 22 |
+
|
| 23 |
+
## GPT Image → Veo (optional)
|
| 24 |
+
|
| 25 |
+
When enabled in the shot-plan step, each segment runs:
|
| 26 |
+
|
| 27 |
+
1. **`POST /api/showcase/segment-first-frame`** — OpenAI **Images `edits`** with **1–4 reference URLs** (scraped gallery or hosted hero), **`input_fidelity: high`**, **`quality: high`**, model from **`GPT_IMAGE_MODEL`** (default **`gpt-image-1.5`**).
|
| 28 |
+
2. The PNG is **hosted** on this API, then passed as the **single `imageUrls` entry** for **`/api/veo/generate`** so Veo animates from a keyframe that matches your product references.
|
| 29 |
+
|
| 30 |
+
Requires **`OPENAI_API_KEY`** and a GPT Image-capable model on your account.
|
| 31 |
+
|
| 32 |
+
## Product URL import
|
| 33 |
+
|
| 34 |
+
- `POST /api/showcase/scrape` with JSON `{ "url": "<product page>" }` returns scraped fields plus `image_urls` and `source_url`.
|
| 35 |
+
- `POST /api/host-image-url` with JSON `{ "url": "<image CDN URL>" }` downloads and hosts the image (same pipeline as upload) so KIE can fetch it.
|
| 36 |
+
|
| 37 |
+
The UI calls these automatically when you use **Import product**.
|
| 38 |
+
|
| 39 |
+
## Configuration
|
| 40 |
+
|
| 41 |
+
- **Env files:** The API loads, in order (later overrides): repo `/.env`, `/.env.local`, `backend/.env`, `backend/.env.local`. Keep secrets in `.env.local` if you prefer.
|
| 42 |
+
- **Frontend env:** Vite `envDir` is the **repo root**. In **development**, the UI uses **relative** `/api` URLs (same-origin) so **EventSource (Veo progress)** works through the Vite proxy — set **`VITE_DEV_PROXY_TARGET`** to your ngrok URL (or `http://127.0.0.1:4010`). Keep **`VITE_API_BASE_URL`** for the **Python app** (public URLs for KIE); the browser does not need to call that host directly unless you set `VITE_PUBLIC_API_IN_DEV=true`.
|
| 43 |
+
- Set `KIE_API_KEY`, `VITE_API_BASE_URL` (public base URL for image hosting and Veo callbacks), and optionally `OPENAI_API_KEY`.
|
| 44 |
+
- **`GET /health`** reports `ffmpeg_available`, `ffprobe_available`, `public_base_url`, and `gpt_image_model` for quick diagnostics.
|
| 45 |
+
- **Callbacks**: KIE must reach `VITE_API_BASE_URL` (use ngrok or a deployed URL if not on localhost).
|
| 46 |
+
|
| 47 |
+
## Run locally
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
# Terminal 1 — API (default port 4010)
|
| 51 |
+
cd backend
|
| 52 |
+
python -m venv .venv && source .venv/bin/activate
|
| 53 |
+
pip install -r requirements.txt
|
| 54 |
+
python main.py
|
| 55 |
+
|
| 56 |
+
# Terminal 2 — UI (proxies /api to 4010)
|
| 57 |
+
cd frontend
|
| 58 |
+
npm install
|
| 59 |
+
npm run dev
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Open `http://localhost:5173`. Ensure `ffmpeg` is available for the final merge step.
|
| 63 |
+
|
| 64 |
+
## Relation to python-backend
|
| 65 |
+
|
| 66 |
+
| Piece | Reference repo | This repo |
|
| 67 |
+
|--------|----------------|-----------|
|
| 68 |
+
| Veo proxy + SSE | `api/video_generation.py` | Copied |
|
| 69 |
+
| Image host | `api/image_service.py` + `utils/image_processor.py` | Copied |
|
| 70 |
+
| Merge | `api/video_export.py` | Copied |
|
| 71 |
+
| UGC / script segmentation | `api/prompt_generation.py` | Replaced by `api/showcase_prompts.py` (product cinematic) |
|
backend/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
backend/api/gpt_image_frames.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-segment first frames via OpenAI GPT Image models (images.edit + multi-image refs).
|
| 3 |
+
Feeds Veo a keyframe that matches 3–4 product reference photos.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import io
|
| 10 |
+
import os
|
| 11 |
+
from typing import Any, List
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from fastapi import APIRouter, HTTPException
|
| 15 |
+
from openai import APIError, AsyncOpenAI
|
| 16 |
+
from pydantic import BaseModel, Field, field_validator
|
| 17 |
+
|
| 18 |
+
from utils.image_processor import compress_and_store_image
|
| 19 |
+
from utils.public_url import get_public_base_url
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
_GPT_IMAGE_DEFAULT = "gpt-image-2"
|
| 24 |
+
_GPT_IMAGE_RETRY_ATTEMPTS = 3
|
| 25 |
+
_PROMPT_REFINER_MODEL_DEFAULT = "gpt-4.1-mini"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _supports_input_fidelity(model: str) -> bool:
|
| 29 |
+
"""gpt-image-2+ models reject `input_fidelity` (invalid_input_fidelity_model)."""
|
| 30 |
+
m = (model or "").strip().lower()
|
| 31 |
+
return m.startswith("gpt-image-1")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _aspect_to_size(aspect: str) -> str:
|
| 35 |
+
a = (aspect or "9:16").strip()
|
| 36 |
+
if a == "9:16":
|
| 37 |
+
return "1024x1536"
|
| 38 |
+
if a == "16:9":
|
| 39 |
+
return "1536x1024"
|
| 40 |
+
return "1024x1024"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _sync_actions_text(segment: dict[str, Any]) -> str:
|
| 44 |
+
at = segment.get("action_timeline") or {}
|
| 45 |
+
sync = at.get("synchronized_actions") or {}
|
| 46 |
+
if isinstance(sync, dict) and sync:
|
| 47 |
+
return "; ".join(f"{k}: {v}" for k, v in sorted(sync.items()))
|
| 48 |
+
return ""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _build_frame_prompt(
|
| 52 |
+
segment: dict[str, Any],
|
| 53 |
+
product_name: str,
|
| 54 |
+
*,
|
| 55 |
+
reference_count: int = 1,
|
| 56 |
+
) -> str:
|
| 57 |
+
sc = segment.get("scene_continuity") or {}
|
| 58 |
+
at = segment.get("action_timeline") or {}
|
| 59 |
+
multi_ref = ""
|
| 60 |
+
if reference_count >= 2:
|
| 61 |
+
multi_ref = (
|
| 62 |
+
f"\nYou are given {reference_count} reference photos of the same product (different angles or crops). "
|
| 63 |
+
"Fuse them into one coherent understanding of the real item: exact shape, materials, proportions, "
|
| 64 |
+
"labels, and distinctive details — not a generic lookalike. Prefer geometry and texture that appear "
|
| 65 |
+
"consistently across the set of references."
|
| 66 |
+
)
|
| 67 |
+
lines = [
|
| 68 |
+
"Generate ONE photorealistic keyframe — the first frame of a premium product video shot.",
|
| 69 |
+
"The product in the references must appear with identical identity: same design, materials, scale, and details. Do not substitute a different SKU or generic item.",
|
| 70 |
+
"No on-image text, logos as graphics, watermarks, or UI. Commercial photography quality.",
|
| 71 |
+
f"Product: {product_name or 'hero product'}.{multi_ref}",
|
| 72 |
+
"",
|
| 73 |
+
"Director notes for this shot:",
|
| 74 |
+
]
|
| 75 |
+
beat = _sync_actions_text(segment)
|
| 76 |
+
if sc.get("camera_movement"):
|
| 77 |
+
lines.append(f"- Camera / motion intent: {sc.get('camera_movement')}")
|
| 78 |
+
if sc.get("lighting_state"):
|
| 79 |
+
lines.append(f"- Lighting: {sc.get('lighting_state')}")
|
| 80 |
+
if sc.get("environment"):
|
| 81 |
+
lines.append(f"- Environment: {sc.get('environment')}")
|
| 82 |
+
if sc.get("camera_position"):
|
| 83 |
+
lines.append(f"- Framing: {sc.get('camera_position')}")
|
| 84 |
+
if beat:
|
| 85 |
+
lines.append(f"- Beat / blocking: {beat}")
|
| 86 |
+
if at.get("dialogue"):
|
| 87 |
+
lines.append(f"- Dialogue / VO mood (do not render text): {at.get('dialogue')}")
|
| 88 |
+
return "\n".join(lines)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _looks_like_safety_failure(detail: str) -> bool:
|
| 92 |
+
d = (detail or "").strip().lower()
|
| 93 |
+
if not d:
|
| 94 |
+
return False
|
| 95 |
+
markers = (
|
| 96 |
+
"moderation_blocked",
|
| 97 |
+
"safety system",
|
| 98 |
+
"safety_violations",
|
| 99 |
+
"image_generation_user_error",
|
| 100 |
+
"request was rejected",
|
| 101 |
+
"content policy",
|
| 102 |
+
)
|
| 103 |
+
return any(m in d for m in markers)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def _refine_prompt_for_retry(
|
| 107 |
+
client: AsyncOpenAI,
|
| 108 |
+
prompt: str,
|
| 109 |
+
failure_detail: str,
|
| 110 |
+
product_name: str,
|
| 111 |
+
) -> str:
|
| 112 |
+
"""
|
| 113 |
+
Ask an LLM to rewrite a blocked image prompt into a policy-safe variant
|
| 114 |
+
while preserving visual intent and product identity.
|
| 115 |
+
"""
|
| 116 |
+
model = (
|
| 117 |
+
os.getenv("OPENAI_PROMPT_REFINER_MODEL") or _PROMPT_REFINER_MODEL_DEFAULT
|
| 118 |
+
).strip() or _PROMPT_REFINER_MODEL_DEFAULT
|
| 119 |
+
try:
|
| 120 |
+
response = await client.responses.create(
|
| 121 |
+
model=model,
|
| 122 |
+
input=[
|
| 123 |
+
{
|
| 124 |
+
"role": "system",
|
| 125 |
+
"content": (
|
| 126 |
+
"Rewrite prompts for image generation to reduce safety rejections. "
|
| 127 |
+
"Keep the same commercial product-shot intent and realism. "
|
| 128 |
+
"Remove or neutralize any sexual, explicit, violent, self-harm, hateful, "
|
| 129 |
+
"or policy-sensitive phrasing. Return plain prompt text only."
|
| 130 |
+
),
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"role": "user",
|
| 134 |
+
"content": (
|
| 135 |
+
f"Product: {product_name or 'hero product'}\n"
|
| 136 |
+
f"Failure detail: {failure_detail}\n\n"
|
| 137 |
+
f"Original prompt:\n{prompt}\n\n"
|
| 138 |
+
"Rewrite this so it is safer while preserving shot composition, "
|
| 139 |
+
"lighting, camera intent, and product continuity."
|
| 140 |
+
),
|
| 141 |
+
},
|
| 142 |
+
],
|
| 143 |
+
max_output_tokens=500,
|
| 144 |
+
)
|
| 145 |
+
refined = (getattr(response, "output_text", "") or "").strip()
|
| 146 |
+
return refined or prompt
|
| 147 |
+
except Exception:
|
| 148 |
+
return prompt
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class SegmentFirstFrameRequest(BaseModel):
|
| 152 |
+
segment: dict[str, Any]
|
| 153 |
+
reference_image_urls: List[str] = Field(..., min_length=1, max_length=4)
|
| 154 |
+
aspect_ratio: str = "9:16"
|
| 155 |
+
product_name: str = ""
|
| 156 |
+
|
| 157 |
+
@field_validator("reference_image_urls")
|
| 158 |
+
@classmethod
|
| 159 |
+
def must_be_http(cls, urls: List[str]) -> List[str]:
|
| 160 |
+
out: List[str] = []
|
| 161 |
+
for u in urls:
|
| 162 |
+
u = (u or "").strip()
|
| 163 |
+
if u.startswith(("http://", "https://")):
|
| 164 |
+
out.append(u)
|
| 165 |
+
if not out:
|
| 166 |
+
raise ValueError("At least one valid http(s) image URL is required")
|
| 167 |
+
return out[:4]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def _download_image(client: httpx.AsyncClient, url: str) -> tuple[bytes, str]:
|
| 171 |
+
r = await client.get(
|
| 172 |
+
url,
|
| 173 |
+
follow_redirects=True,
|
| 174 |
+
timeout=30.0,
|
| 175 |
+
headers={"User-Agent": "ProductShowcase/1.0"},
|
| 176 |
+
)
|
| 177 |
+
r.raise_for_status()
|
| 178 |
+
ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
|
| 179 |
+
if not ct.startswith("image/"):
|
| 180 |
+
raise ValueError(f"Not an image: {url} ({ct})")
|
| 181 |
+
return r.content, ct
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.post("/showcase/segment-first-frame")
|
| 185 |
+
async def generate_segment_first_frame(body: SegmentFirstFrameRequest):
|
| 186 |
+
"""
|
| 187 |
+
Uses OpenAI Images `edits` with 1–4 reference images to synthesize a segment keyframe,
|
| 188 |
+
then hosts it for Veo (same as upload-image pipeline).
|
| 189 |
+
"""
|
| 190 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 191 |
+
if not api_key:
|
| 192 |
+
raise HTTPException(
|
| 193 |
+
status_code=503,
|
| 194 |
+
detail="OPENAI_API_KEY is required for GPT Image first frames.",
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
model = (os.getenv("GPT_IMAGE_MODEL") or _GPT_IMAGE_DEFAULT).strip() or _GPT_IMAGE_DEFAULT
|
| 198 |
+
public_url = get_public_base_url()
|
| 199 |
+
|
| 200 |
+
n_refs = len(body.reference_image_urls)
|
| 201 |
+
prompt = _build_frame_prompt(body.segment, body.product_name, reference_count=n_refs)
|
| 202 |
+
size = _aspect_to_size(body.aspect_ratio)
|
| 203 |
+
|
| 204 |
+
file_tuples: list[tuple[str, io.BytesIO, str]] = []
|
| 205 |
+
try:
|
| 206 |
+
urls_in_order = body.reference_image_urls[:4]
|
| 207 |
+
async with httpx.AsyncClient() as dl:
|
| 208 |
+
downloaded = await asyncio.gather(
|
| 209 |
+
*(_download_image(dl, url) for url in urls_in_order)
|
| 210 |
+
)
|
| 211 |
+
for i, (raw, ctype) in enumerate(downloaded):
|
| 212 |
+
ext = "png" if "png" in ctype else "jpeg"
|
| 213 |
+
file_tuples.append((f"ref_{i}.{ext}", io.BytesIO(raw), ctype))
|
| 214 |
+
except httpx.HTTPError as e:
|
| 215 |
+
raise HTTPException(status_code=502, detail=f"Could not download reference image: {e}")
|
| 216 |
+
except ValueError as e:
|
| 217 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 218 |
+
|
| 219 |
+
client = AsyncOpenAI(api_key=api_key)
|
| 220 |
+
result = None
|
| 221 |
+
prompt_for_attempt = prompt
|
| 222 |
+
last_detail = ""
|
| 223 |
+
for attempt in range(1, _GPT_IMAGE_RETRY_ATTEMPTS + 1):
|
| 224 |
+
try:
|
| 225 |
+
if _supports_input_fidelity(model):
|
| 226 |
+
result = await client.images.edit(
|
| 227 |
+
model=model,
|
| 228 |
+
image=file_tuples,
|
| 229 |
+
prompt=prompt_for_attempt,
|
| 230 |
+
size=size, # type: ignore[arg-type]
|
| 231 |
+
quality="high",
|
| 232 |
+
input_fidelity="high",
|
| 233 |
+
output_format="png",
|
| 234 |
+
)
|
| 235 |
+
else:
|
| 236 |
+
result = await client.images.edit(
|
| 237 |
+
model=model,
|
| 238 |
+
image=file_tuples,
|
| 239 |
+
prompt=prompt_for_attempt,
|
| 240 |
+
size=size, # type: ignore[arg-type]
|
| 241 |
+
quality="high",
|
| 242 |
+
output_format="png",
|
| 243 |
+
)
|
| 244 |
+
break
|
| 245 |
+
except Exception as e:
|
| 246 |
+
detail = e.message if isinstance(e, APIError) else str(e)
|
| 247 |
+
last_detail = detail
|
| 248 |
+
should_retry = attempt < _GPT_IMAGE_RETRY_ATTEMPTS and _looks_like_safety_failure(
|
| 249 |
+
detail
|
| 250 |
+
)
|
| 251 |
+
if not should_retry:
|
| 252 |
+
raise HTTPException(
|
| 253 |
+
status_code=502,
|
| 254 |
+
detail=f"OpenAI image edit failed ({model}): {detail}",
|
| 255 |
+
)
|
| 256 |
+
prompt_for_attempt = await _refine_prompt_for_retry(
|
| 257 |
+
client,
|
| 258 |
+
prompt_for_attempt,
|
| 259 |
+
detail,
|
| 260 |
+
body.product_name,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
if result is None:
|
| 264 |
+
raise HTTPException(
|
| 265 |
+
status_code=502,
|
| 266 |
+
detail=f"OpenAI image edit failed ({model}) after {_GPT_IMAGE_RETRY_ATTEMPTS} attempts: {last_detail}",
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
if not result.data or not result.data[0].b64_json:
|
| 270 |
+
raise HTTPException(
|
| 271 |
+
status_code=502,
|
| 272 |
+
detail="OpenAI returned no image (b64_json missing). Check model access and billing.",
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
b64 = result.data[0].b64_json
|
| 276 |
+
data_url = f"data:image/png;base64,{b64}"
|
| 277 |
+
|
| 278 |
+
try:
|
| 279 |
+
hosted = await compress_and_store_image(
|
| 280 |
+
data_url,
|
| 281 |
+
public_url,
|
| 282 |
+
max_width=1920,
|
| 283 |
+
max_height=1080,
|
| 284 |
+
quality=92,
|
| 285 |
+
)
|
| 286 |
+
except Exception as e:
|
| 287 |
+
raise HTTPException(status_code=500, detail=f"Could not host generated frame: {e}")
|
| 288 |
+
|
| 289 |
+
return {
|
| 290 |
+
"url": hosted,
|
| 291 |
+
"model": model,
|
| 292 |
+
"size": size,
|
| 293 |
+
}
|
backend/api/image_service.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image Service API endpoints
|
| 3 |
+
Handles image compression, storage, and serving
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from fastapi import APIRouter, HTTPException, Response, UploadFile, File, Query
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from utils.public_url import get_public_base_url
|
| 15 |
+
from utils.storage import temp_images
|
| 16 |
+
from utils.image_processor import compress_and_store_image
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
# High-quality settings for reference/continuity frames (last frame of previous segment)
|
| 21 |
+
REFERENCE_FRAME_QUALITY = 92
|
| 22 |
+
REFERENCE_FRAME_MAX_WIDTH = 1920
|
| 23 |
+
REFERENCE_FRAME_MAX_HEIGHT = 1080
|
| 24 |
+
|
| 25 |
+
def _is_reference_frame_filename(filename: str) -> bool:
|
| 26 |
+
if not filename:
|
| 27 |
+
return False
|
| 28 |
+
name = filename.lower()
|
| 29 |
+
return bool(re.match(r"^(frame-|last-frame\.|whisper-frame-)", name) or "frame" in name and name.endswith((".jpg", ".jpeg", ".png")))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/upload-image")
|
| 33 |
+
async def upload_image(
|
| 34 |
+
file: UploadFile = File(...),
|
| 35 |
+
reference: bool = Query(False, description="High quality for last-frame/reference uploads"),
|
| 36 |
+
):
|
| 37 |
+
"""
|
| 38 |
+
Upload and host an image, returns public URL.
|
| 39 |
+
Use ?reference=true when uploading a continuity/reference frame (last frame of previous segment)
|
| 40 |
+
for higher quality and less downscaling.
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
image_bytes = await file.read()
|
| 44 |
+
encoded = base64.b64encode(image_bytes).decode('utf-8')
|
| 45 |
+
data_url = f"data:{file.content_type or 'image/jpeg'};base64,{encoded}"
|
| 46 |
+
public_url = get_public_base_url()
|
| 47 |
+
|
| 48 |
+
use_high_quality = reference or _is_reference_frame_filename(file.filename or "")
|
| 49 |
+
if use_high_quality:
|
| 50 |
+
hosted_url = await compress_and_store_image(
|
| 51 |
+
data_url, public_url,
|
| 52 |
+
max_width=REFERENCE_FRAME_MAX_WIDTH,
|
| 53 |
+
max_height=REFERENCE_FRAME_MAX_HEIGHT,
|
| 54 |
+
quality=REFERENCE_FRAME_QUALITY,
|
| 55 |
+
)
|
| 56 |
+
else:
|
| 57 |
+
hosted_url = await compress_and_store_image(data_url, public_url)
|
| 58 |
+
|
| 59 |
+
return JSONResponse(content={
|
| 60 |
+
"url": hosted_url,
|
| 61 |
+
"filename": file.filename,
|
| 62 |
+
})
|
| 63 |
+
except Exception as e:
|
| 64 |
+
raise HTTPException(status_code=500, detail=f"Image upload failed: {str(e)}")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class HostImageUrlBody(BaseModel):
|
| 68 |
+
url: str = Field(..., min_length=8, description="Public HTTPS image URL (e.g. CDN)")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.post("/host-image-url")
|
| 72 |
+
async def host_image_from_url(body: HostImageUrlBody):
|
| 73 |
+
"""
|
| 74 |
+
Download an image server-side and host it like /upload-image (reference quality).
|
| 75 |
+
Used after scraping so KIE and the planner can use a stable public URL.
|
| 76 |
+
"""
|
| 77 |
+
url = body.url.strip()
|
| 78 |
+
try:
|
| 79 |
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 80 |
+
r = await client.get(url, headers={"User-Agent": "ProductShowcase/1.0"})
|
| 81 |
+
r.raise_for_status()
|
| 82 |
+
content = r.content
|
| 83 |
+
ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip() or "image/jpeg"
|
| 84 |
+
if not ct.startswith("image/"):
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=400,
|
| 87 |
+
detail=f"URL did not return an image (content-type: {ct})",
|
| 88 |
+
)
|
| 89 |
+
except httpx.HTTPError as e:
|
| 90 |
+
raise HTTPException(status_code=502, detail=f"Could not download image: {e}")
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
b64 = base64.b64encode(content).decode("utf-8")
|
| 94 |
+
data_url = f"data:{ct};base64,{b64}"
|
| 95 |
+
public_url = get_public_base_url()
|
| 96 |
+
hosted_url = await compress_and_store_image(
|
| 97 |
+
data_url,
|
| 98 |
+
public_url,
|
| 99 |
+
max_width=REFERENCE_FRAME_MAX_WIDTH,
|
| 100 |
+
max_height=REFERENCE_FRAME_MAX_HEIGHT,
|
| 101 |
+
quality=REFERENCE_FRAME_QUALITY,
|
| 102 |
+
)
|
| 103 |
+
return JSONResponse(content={"url": hosted_url, "source_url": url})
|
| 104 |
+
except Exception as e:
|
| 105 |
+
raise HTTPException(status_code=500, detail=f"Host image failed: {str(e)}")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.get("/images/{image_id}")
|
| 109 |
+
async def serve_image(image_id: str):
|
| 110 |
+
"""
|
| 111 |
+
Serve temporarily stored images
|
| 112 |
+
Images are compressed and cached for 1 hour
|
| 113 |
+
"""
|
| 114 |
+
if image_id not in temp_images:
|
| 115 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 116 |
+
|
| 117 |
+
image_data = temp_images[image_id]
|
| 118 |
+
|
| 119 |
+
return Response(
|
| 120 |
+
content=image_data['buffer'],
|
| 121 |
+
media_type=image_data['content_type'],
|
| 122 |
+
headers={
|
| 123 |
+
'Cache-Control': 'public, max-age=3600'
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
|
backend/api/scraper.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scrape product data from an Amalfa product page URL.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
import requests
|
| 11 |
+
from bs4 import BeautifulSoup
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _clean_text(s: str) -> str:
|
| 15 |
+
if not s:
|
| 16 |
+
return ""
|
| 17 |
+
return " ".join(s.split()).strip()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _extract_price_from_text(text: str) -> str:
|
| 21 |
+
"""Find first price like Rs 1,299 or ₹1299."""
|
| 22 |
+
if not text:
|
| 23 |
+
return ""
|
| 24 |
+
m = re.search(r"(?:Rs\.?|₹)\s*([\d,]+(?:\.\d{2})?)", text, re.I)
|
| 25 |
+
if m:
|
| 26 |
+
return m.group(0).strip()
|
| 27 |
+
m = re.search(r"[\d,]+(?:\.\d{2})?", text)
|
| 28 |
+
if m:
|
| 29 |
+
return m.group(0)
|
| 30 |
+
return ""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def scrape_product(url: str) -> dict[str, Any]:
|
| 34 |
+
"""
|
| 35 |
+
Fetch an Amalfa product page and extract product_name, description, price,
|
| 36 |
+
offers, product_images, brand, category. Strategy fields left empty for AI/user.
|
| 37 |
+
"""
|
| 38 |
+
parsed = urlparse(url)
|
| 39 |
+
if not parsed.scheme or not parsed.netloc:
|
| 40 |
+
raise ValueError(f"Invalid URL: {url}")
|
| 41 |
+
|
| 42 |
+
headers = {
|
| 43 |
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 44 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 45 |
+
"Accept-Language": "en-IN,en;q=0.9",
|
| 46 |
+
}
|
| 47 |
+
resp = requests.get(url, headers=headers, timeout=15)
|
| 48 |
+
resp.raise_for_status()
|
| 49 |
+
html = resp.text
|
| 50 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 51 |
+
|
| 52 |
+
product: dict[str, Any] = {
|
| 53 |
+
"product_name": "",
|
| 54 |
+
"description": "",
|
| 55 |
+
"price": "",
|
| 56 |
+
"offers": "",
|
| 57 |
+
"product_images": "",
|
| 58 |
+
"brand": "",
|
| 59 |
+
"category": "",
|
| 60 |
+
"target_audience": "",
|
| 61 |
+
"competitors": "",
|
| 62 |
+
"psychological_triggers": "",
|
| 63 |
+
"show_product": None,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
for script in soup.find_all("script", type="application/ld+json"):
|
| 67 |
+
try:
|
| 68 |
+
data = json.loads(script.string or "{}")
|
| 69 |
+
if isinstance(data, dict) and data.get("@type") == "Product":
|
| 70 |
+
product["product_name"] = _clean_text(data.get("name") or "")
|
| 71 |
+
product["description"] = _clean_text(data.get("description") or "")
|
| 72 |
+
if data.get("offers") and isinstance(data["offers"], dict):
|
| 73 |
+
product["price"] = str(data["offers"].get("price", ""))
|
| 74 |
+
elif isinstance(data.get("offers"), list) and data["offers"]:
|
| 75 |
+
product["price"] = str(data["offers"][0].get("price", ""))
|
| 76 |
+
if data.get("image"):
|
| 77 |
+
imgs = data["image"] if isinstance(data["image"], list) else [data["image"]]
|
| 78 |
+
product["product_images"] = ", ".join(str(u).strip() for u in imgs[:9] if u)
|
| 79 |
+
if product["product_name"] and product["price"]:
|
| 80 |
+
break
|
| 81 |
+
except (json.JSONDecodeError, TypeError):
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
if not product["product_name"]:
|
| 85 |
+
meta = soup.find("meta", property="og:title")
|
| 86 |
+
if meta and meta.get("content"):
|
| 87 |
+
product["product_name"] = _clean_text(meta["content"].split("|")[0].strip())
|
| 88 |
+
if not product["description"]:
|
| 89 |
+
meta = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 90 |
+
if meta and meta.get("content"):
|
| 91 |
+
product["description"] = _clean_text(meta["content"])
|
| 92 |
+
if not product["product_images"]:
|
| 93 |
+
meta = soup.find("meta", property="og:image")
|
| 94 |
+
if meta and meta.get("content"):
|
| 95 |
+
product["product_images"] = meta["content"].strip()
|
| 96 |
+
|
| 97 |
+
if not product["product_name"]:
|
| 98 |
+
h1 = soup.find("h1")
|
| 99 |
+
if h1:
|
| 100 |
+
product["product_name"] = _clean_text(h1.get_text())
|
| 101 |
+
|
| 102 |
+
if not product["price"]:
|
| 103 |
+
for sel in ["[class*='price']", ".product__price", "[data-product-price]", ".price-item"]:
|
| 104 |
+
el = soup.select_one(sel)
|
| 105 |
+
if el:
|
| 106 |
+
product["price"] = _extract_price_from_text(el.get_text())
|
| 107 |
+
if product["price"]:
|
| 108 |
+
break
|
| 109 |
+
if not product["price"]:
|
| 110 |
+
product["price"] = _extract_price_from_text(soup.get_text())
|
| 111 |
+
|
| 112 |
+
if not product["description"]:
|
| 113 |
+
desc_el = (
|
| 114 |
+
soup.find("div", class_=re.compile(r"description|product-description|product__description", re.I))
|
| 115 |
+
or soup.find("meta", attrs={"name": "description"})
|
| 116 |
+
)
|
| 117 |
+
if desc_el:
|
| 118 |
+
product["description"] = _clean_text(
|
| 119 |
+
desc_el.get_text() if hasattr(desc_el, "get_text") else (desc_el.get("content") or "")
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Shopify product JSON has the full images list (primary source for product images)
|
| 123 |
+
path_parts = (parsed.path or "").strip("/").split("/")
|
| 124 |
+
if path_parts and path_parts[0] == "products" and len(path_parts) >= 2:
|
| 125 |
+
handle = path_parts[1]
|
| 126 |
+
product_json_url = f"{parsed.scheme}://{parsed.netloc}/products/{handle}.json"
|
| 127 |
+
try:
|
| 128 |
+
r = requests.get(product_json_url, headers={**headers, "Accept": "application/json"}, timeout=10)
|
| 129 |
+
if r.ok:
|
| 130 |
+
data = r.json()
|
| 131 |
+
# Shopify Ajax API: root is the product object, or wrapped as {"product": {...}}
|
| 132 |
+
prod = data.get("product") if isinstance(data.get("product"), dict) else data
|
| 133 |
+
if isinstance(prod, dict):
|
| 134 |
+
images = prod.get("images")
|
| 135 |
+
if isinstance(images, list) and len(images) >= 1:
|
| 136 |
+
urls = []
|
| 137 |
+
for img in images[:9]:
|
| 138 |
+
u = None
|
| 139 |
+
if isinstance(img, dict) and img.get("src"):
|
| 140 |
+
u = (img.get("src") or "").strip()
|
| 141 |
+
elif isinstance(img, str) and img.strip():
|
| 142 |
+
u = img.strip()
|
| 143 |
+
if u:
|
| 144 |
+
if u.startswith("//"):
|
| 145 |
+
u = "https:" + u
|
| 146 |
+
if u.startswith("http") and u not in urls:
|
| 147 |
+
urls.append(u)
|
| 148 |
+
if urls:
|
| 149 |
+
product["product_images"] = ", ".join(urls)
|
| 150 |
+
except (requests.RequestException, ValueError, KeyError):
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
+
path = (parsed.path or "").lower()
|
| 154 |
+
if "earring" in path:
|
| 155 |
+
product["category"] = product["category"] or "Earrings"
|
| 156 |
+
elif "necklace" in path or "pendant" in path or "choker" in path:
|
| 157 |
+
product["category"] = product["category"] or "Necklaces"
|
| 158 |
+
elif "ring" in path:
|
| 159 |
+
product["category"] = product["category"] or "Rings"
|
| 160 |
+
elif "bracelet" in path or "bangle" in path:
|
| 161 |
+
product["category"] = product["category"] or "Bracelets"
|
| 162 |
+
elif "anklet" in path:
|
| 163 |
+
product["category"] = product["category"] or "Anklets"
|
| 164 |
+
|
| 165 |
+
if not product["category"]:
|
| 166 |
+
product["category"] = "Jewellery"
|
| 167 |
+
|
| 168 |
+
# Log scraped data for verification (especially product images)
|
| 169 |
+
_images = [u.strip() for u in (product.get("product_images") or "").split(",") if u.strip()]
|
| 170 |
+
print(
|
| 171 |
+
"[scraper] product_name=%r category=%r | product_images count=%d | urls=%s"
|
| 172 |
+
% (product.get("product_name"), product.get("category"), len(_images), _images)
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
return product
|
backend/api/scraper_routes.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API for product page scraping."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
from api.scraper import scrape_product
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ScrapeRequest(BaseModel):
|
| 17 |
+
url: str = Field(..., min_length=8, description="Product page URL (e.g. Amalfa / Shopify)")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _image_url_list(product: dict[str, Any]) -> list[str]:
|
| 21 |
+
raw = product.get("product_images") or ""
|
| 22 |
+
return [u.strip() for u in str(raw).split(",") if u.strip()]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("/showcase/scrape")
|
| 26 |
+
async def scrape_product_endpoint(body: ScrapeRequest) -> dict[str, Any]:
|
| 27 |
+
url = body.url.strip()
|
| 28 |
+
try:
|
| 29 |
+
product = await asyncio.to_thread(scrape_product, url)
|
| 30 |
+
except ValueError as e:
|
| 31 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 32 |
+
except Exception as e:
|
| 33 |
+
raise HTTPException(status_code=502, detail=f"Scrape failed: {e}")
|
| 34 |
+
|
| 35 |
+
product = dict(product)
|
| 36 |
+
product["image_urls"] = _image_url_list(product)
|
| 37 |
+
product["source_url"] = url
|
| 38 |
+
return product
|
backend/api/seedance_generation.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KIE Seedance 2 — jobs/createTask + jobs/recordInfo.
|
| 3 |
+
Models: bytedance/seedance-2, bytedance/seedance-2-fast.
|
| 4 |
+
Falls back to Replicate (bytedance/seedance-2.0 / seedance-2.0-fast) when KIE errors.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import asyncio
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 16 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 17 |
+
from pydantic import BaseModel, Field, field_validator
|
| 18 |
+
|
| 19 |
+
from utils.public_url import get_public_base_url
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
KIE_API_BASE = "https://api.kie.ai"
|
| 25 |
+
REPLICATE_API_BASE = "https://api.replicate.com/v1"
|
| 26 |
+
REPLICATE_TASK_PREFIX = "repl_"
|
| 27 |
+
DEFAULT_SEEDANCE_MODEL = "bytedance/seedance-2-fast"
|
| 28 |
+
ALLOWED_SEEDANCE_MODELS = frozenset(
|
| 29 |
+
{DEFAULT_SEEDANCE_MODEL, "bytedance/seedance-2"},
|
| 30 |
+
)
|
| 31 |
+
KIE_TO_REPLICATE_SEEDANCE_MODEL = {
|
| 32 |
+
"bytedance/seedance-2": "bytedance/seedance-2.0",
|
| 33 |
+
"bytedance/seedance-2-fast": "bytedance/seedance-2.0-fast",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# In-memory callback/SSE state for Seedance tasks
|
| 37 |
+
seedance_results: dict[str, dict] = {}
|
| 38 |
+
seedance_sse_clients: dict[str, asyncio.Queue] = {}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _cleanup_old_seedance_results(max_age_hours: int = 24) -> None:
|
| 42 |
+
cutoff = datetime.now().timestamp() - (max_age_hours * 3600)
|
| 43 |
+
stale = [task_id for task_id, data in seedance_results.items() if data.get("timestamp", 0) < cutoff]
|
| 44 |
+
for task_id in stale:
|
| 45 |
+
del seedance_results[task_id]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _kie_key() -> str:
|
| 49 |
+
api_key = os.getenv("KIE_API_KEY")
|
| 50 |
+
if not api_key:
|
| 51 |
+
raise HTTPException(status_code=500, detail="KIE_API_KEY not configured on server.")
|
| 52 |
+
return api_key
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _replicate_token() -> str | None:
|
| 56 |
+
raw = (os.getenv("REPLICATE_API_TOKEN") or os.getenv("REPLICATE_API_KEY") or "").strip()
|
| 57 |
+
return raw or None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _http_url_for_provider(u: str, public_base: str) -> str:
|
| 61 |
+
"""Replicate requires http(s) URIs. Map asset:// to our public base if needed."""
|
| 62 |
+
s = (u or "").strip()
|
| 63 |
+
if s.startswith(("http://", "https://")):
|
| 64 |
+
return s
|
| 65 |
+
if s.startswith("asset://"):
|
| 66 |
+
rest = s[9:].lstrip("/")
|
| 67 |
+
return f"{public_base.rstrip('/')}/{rest}"
|
| 68 |
+
return s
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def _kie_seedance_create_task(payload: dict, api_key: str) -> str:
|
| 72 |
+
try:
|
| 73 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 74 |
+
r = await client.post(
|
| 75 |
+
f"{KIE_API_BASE}/api/v1/jobs/createTask",
|
| 76 |
+
headers={
|
| 77 |
+
"Authorization": f"Bearer {api_key}",
|
| 78 |
+
"Content-Type": "application/json",
|
| 79 |
+
},
|
| 80 |
+
json=payload,
|
| 81 |
+
)
|
| 82 |
+
except httpx.RequestError as e:
|
| 83 |
+
raise HTTPException(status_code=502, detail=f"KIE request error: {e}") from e
|
| 84 |
+
|
| 85 |
+
if r.status_code != 200:
|
| 86 |
+
try:
|
| 87 |
+
err = r.json()
|
| 88 |
+
msg = err.get("msg") or err.get("message") or r.text[:300]
|
| 89 |
+
except (json.JSONDecodeError, ValueError):
|
| 90 |
+
msg = r.text[:300]
|
| 91 |
+
raise HTTPException(status_code=r.status_code, detail=f"KIE createTask failed: {msg}")
|
| 92 |
+
|
| 93 |
+
data = r.json()
|
| 94 |
+
if data.get("code") != 200:
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=data.get("code", 502),
|
| 97 |
+
detail=data.get("msg", "KIE createTask rejected"),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
task_id = (data.get("data") or {}).get("taskId")
|
| 101 |
+
if not task_id:
|
| 102 |
+
raise HTTPException(status_code=502, detail="KIE response missing taskId")
|
| 103 |
+
return task_id
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _replicate_input_from_seedance_urls(
|
| 107 |
+
urls: List[str],
|
| 108 |
+
prompt: str,
|
| 109 |
+
aspect_ratio: str,
|
| 110 |
+
duration: int,
|
| 111 |
+
resolution: str,
|
| 112 |
+
generate_audio: bool,
|
| 113 |
+
public_base: str,
|
| 114 |
+
) -> dict:
|
| 115 |
+
mapped = [_http_url_for_provider(u, public_base) for u in urls]
|
| 116 |
+
mapped = [u for u in mapped if u.startswith(("http://", "https://"))]
|
| 117 |
+
if not mapped:
|
| 118 |
+
raise HTTPException(
|
| 119 |
+
status_code=400,
|
| 120 |
+
detail="Replicate requires public http(s) image URLs (convert or host assets first).",
|
| 121 |
+
)
|
| 122 |
+
n = len(mapped)
|
| 123 |
+
inp: dict = {
|
| 124 |
+
"prompt": prompt,
|
| 125 |
+
"generate_audio": generate_audio,
|
| 126 |
+
"resolution": resolution,
|
| 127 |
+
"aspect_ratio": aspect_ratio,
|
| 128 |
+
"duration": duration,
|
| 129 |
+
}
|
| 130 |
+
if n == 1:
|
| 131 |
+
inp["image"] = mapped[0]
|
| 132 |
+
elif n == 2:
|
| 133 |
+
inp["image"] = mapped[0]
|
| 134 |
+
inp["last_frame_image"] = mapped[1]
|
| 135 |
+
else:
|
| 136 |
+
inp["reference_images"] = mapped[:9]
|
| 137 |
+
return inp
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def _replicate_seedance_start(
|
| 141 |
+
model_slug: str,
|
| 142 |
+
input_obj: dict,
|
| 143 |
+
token: str,
|
| 144 |
+
) -> str:
|
| 145 |
+
try:
|
| 146 |
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
| 147 |
+
r = await client.post(
|
| 148 |
+
f"{REPLICATE_API_BASE}/models/{model_slug}/predictions",
|
| 149 |
+
headers={
|
| 150 |
+
"Authorization": f"Bearer {token}",
|
| 151 |
+
"Content-Type": "application/json",
|
| 152 |
+
},
|
| 153 |
+
json={"input": input_obj},
|
| 154 |
+
)
|
| 155 |
+
except httpx.RequestError as e:
|
| 156 |
+
raise HTTPException(status_code=502, detail=f"Replicate request error: {e}") from e
|
| 157 |
+
|
| 158 |
+
if r.status_code not in (200, 201):
|
| 159 |
+
try:
|
| 160 |
+
err = r.json()
|
| 161 |
+
detail = err.get("detail") or err.get("message") or r.text[:500]
|
| 162 |
+
except (json.JSONDecodeError, ValueError):
|
| 163 |
+
detail = r.text[:500]
|
| 164 |
+
raise HTTPException(
|
| 165 |
+
status_code=502,
|
| 166 |
+
detail=f"Replicate create prediction failed ({r.status_code}): {detail}",
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
data = r.json()
|
| 170 |
+
pred_id = data.get("id")
|
| 171 |
+
if not pred_id:
|
| 172 |
+
raise HTTPException(status_code=502, detail="Replicate response missing prediction id")
|
| 173 |
+
return f"{REPLICATE_TASK_PREFIX}{pred_id}"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def _replicate_prediction_fetch(prediction_id: str, token: str) -> dict:
|
| 177 |
+
try:
|
| 178 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 179 |
+
r = await client.get(
|
| 180 |
+
f"{REPLICATE_API_BASE}/predictions/{prediction_id}",
|
| 181 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 182 |
+
)
|
| 183 |
+
except httpx.RequestError as e:
|
| 184 |
+
raise HTTPException(status_code=502, detail=f"Replicate request error: {e}") from e
|
| 185 |
+
|
| 186 |
+
if r.status_code != 200:
|
| 187 |
+
raise HTTPException(status_code=502, detail="Replicate prediction fetch failed")
|
| 188 |
+
|
| 189 |
+
return r.json()
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _replicate_prediction_to_job_shape(body: dict) -> dict:
|
| 193 |
+
status = (body.get("status") or "").lower()
|
| 194 |
+
err = body.get("error")
|
| 195 |
+
out_url = None
|
| 196 |
+
output = body.get("output")
|
| 197 |
+
if isinstance(output, str):
|
| 198 |
+
out_url = output
|
| 199 |
+
elif isinstance(output, list) and output:
|
| 200 |
+
first = output[0]
|
| 201 |
+
out_url = first if isinstance(first, str) else None
|
| 202 |
+
|
| 203 |
+
if status == "succeeded":
|
| 204 |
+
state = "success"
|
| 205 |
+
elif status in {"failed", "canceled", "cancelled"}:
|
| 206 |
+
state = "fail"
|
| 207 |
+
else:
|
| 208 |
+
state = "processing"
|
| 209 |
+
|
| 210 |
+
fail_msg = None
|
| 211 |
+
if state == "fail":
|
| 212 |
+
fail_msg = err if isinstance(err, str) else (str(err) if err else "Replicate prediction failed")
|
| 213 |
+
|
| 214 |
+
return {
|
| 215 |
+
"state": state,
|
| 216 |
+
"url": out_url,
|
| 217 |
+
"failMsg": fail_msg,
|
| 218 |
+
"failCode": None,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _clamp_seedance_duration(seconds: int) -> int:
|
| 223 |
+
if seconds < 4:
|
| 224 |
+
return 4
|
| 225 |
+
if seconds > 15:
|
| 226 |
+
return 15
|
| 227 |
+
return int(seconds)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _valid_media_ref(u: str) -> bool:
|
| 231 |
+
s = str(u).strip()
|
| 232 |
+
return bool(s.startswith(("http://", "https://", "asset://")))
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _normalize_seedance_prompt(raw: str) -> str:
|
| 236 |
+
"""OpenAPI: prompt required, 3–20000 chars."""
|
| 237 |
+
p = (raw or "").strip()
|
| 238 |
+
if not p:
|
| 239 |
+
p = "Cinematic premium product showcase, photoreal, smooth camera motion."
|
| 240 |
+
if len(p) < 3:
|
| 241 |
+
p = f"{p} — product video."
|
| 242 |
+
if len(p) > 20000:
|
| 243 |
+
p = p[:20000]
|
| 244 |
+
return p
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _seedance_input_from_urls(
|
| 248 |
+
urls: List[str],
|
| 249 |
+
prompt: str,
|
| 250 |
+
aspect_ratio: str,
|
| 251 |
+
duration: int,
|
| 252 |
+
resolution: str,
|
| 253 |
+
generate_audio: bool,
|
| 254 |
+
) -> dict:
|
| 255 |
+
"""
|
| 256 |
+
Seedance modes are mutually exclusive (OpenAPI):
|
| 257 |
+
- 1 URL → image-to-video (first frame only): first_frame_url
|
| 258 |
+
- 2 URLs → first & last frames: first_frame_url + last_frame_url
|
| 259 |
+
- 3+ URLs → multimodal reference-to-video: reference_image_urls only (max 9)
|
| 260 |
+
Do not mix first/last/reference in one request.
|
| 261 |
+
"""
|
| 262 |
+
n = len(urls)
|
| 263 |
+
base = {
|
| 264 |
+
"prompt": prompt,
|
| 265 |
+
"generate_audio": generate_audio,
|
| 266 |
+
"resolution": resolution,
|
| 267 |
+
"aspect_ratio": aspect_ratio,
|
| 268 |
+
"duration": duration,
|
| 269 |
+
"nsfw_checker": False,
|
| 270 |
+
}
|
| 271 |
+
if n == 1:
|
| 272 |
+
base["first_frame_url"] = urls[0]
|
| 273 |
+
elif n == 2:
|
| 274 |
+
base["first_frame_url"] = urls[0]
|
| 275 |
+
base["last_frame_url"] = urls[1]
|
| 276 |
+
else:
|
| 277 |
+
base["reference_image_urls"] = urls[:9]
|
| 278 |
+
return base
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class SeedanceCreateBody(BaseModel):
|
| 282 |
+
prompt: str = Field(..., max_length=20000)
|
| 283 |
+
reference_image_urls: List[str] = Field(..., min_length=1)
|
| 284 |
+
aspect_ratio: str = "9:16"
|
| 285 |
+
duration: int = 8
|
| 286 |
+
resolution: str = "480p"
|
| 287 |
+
generate_audio: bool = True
|
| 288 |
+
model: str = Field(default=DEFAULT_SEEDANCE_MODEL, max_length=120)
|
| 289 |
+
|
| 290 |
+
@field_validator("resolution")
|
| 291 |
+
@classmethod
|
| 292 |
+
def resolution_ok(cls, v: str) -> str:
|
| 293 |
+
if v not in ("480p", "720p", "1080p"):
|
| 294 |
+
raise ValueError("resolution must be 480p, 720p, or 1080p")
|
| 295 |
+
return v
|
| 296 |
+
|
| 297 |
+
@field_validator("model")
|
| 298 |
+
@classmethod
|
| 299 |
+
def seedance_model_ok(cls, v: str) -> str:
|
| 300 |
+
m = (v or "").strip()
|
| 301 |
+
if m not in ALLOWED_SEEDANCE_MODELS:
|
| 302 |
+
raise ValueError(
|
| 303 |
+
f"model must be one of: {', '.join(sorted(ALLOWED_SEEDANCE_MODELS))}"
|
| 304 |
+
)
|
| 305 |
+
return m
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
class SeedanceCreateResponse(BaseModel):
|
| 309 |
+
taskId: str
|
| 310 |
+
status: str = "processing"
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
async def _send_seedance_sse_event(task_id: str, data: dict) -> None:
|
| 314 |
+
queue = seedance_sse_clients.get(task_id)
|
| 315 |
+
if queue is not None:
|
| 316 |
+
await queue.put(data)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _extract_seedance_callback_state(payload: dict) -> dict:
|
| 320 |
+
"""
|
| 321 |
+
Normalize KIE callback payloads into a browser-friendly shape.
|
| 322 |
+
Supports both:
|
| 323 |
+
- jobs callback with `data.info.state/resultJson`
|
| 324 |
+
- direct callback payloads with `data.state/resultJson`
|
| 325 |
+
"""
|
| 326 |
+
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
| 327 |
+
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
| 328 |
+
src = info or data
|
| 329 |
+
|
| 330 |
+
state = src.get("state")
|
| 331 |
+
fail_msg = src.get("failMsg") or payload.get("msg")
|
| 332 |
+
fail_code = src.get("failCode")
|
| 333 |
+
url = None
|
| 334 |
+
result_json = src.get("resultJson")
|
| 335 |
+
if result_json:
|
| 336 |
+
try:
|
| 337 |
+
parsed = json.loads(result_json) if isinstance(result_json, str) else result_json
|
| 338 |
+
urls = parsed.get("resultUrls") or []
|
| 339 |
+
if urls:
|
| 340 |
+
url = urls[0]
|
| 341 |
+
except (json.JSONDecodeError, TypeError, ValueError, AttributeError):
|
| 342 |
+
pass
|
| 343 |
+
|
| 344 |
+
if not state:
|
| 345 |
+
code = payload.get("code")
|
| 346 |
+
if code == 200 and url:
|
| 347 |
+
state = "success"
|
| 348 |
+
elif code not in (None, 200):
|
| 349 |
+
state = "fail"
|
| 350 |
+
else:
|
| 351 |
+
state = "processing"
|
| 352 |
+
|
| 353 |
+
return {
|
| 354 |
+
"state": state,
|
| 355 |
+
"url": url,
|
| 356 |
+
"failMsg": fail_msg,
|
| 357 |
+
"failCode": fail_code,
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@router.post("/seedance/create", response_model=SeedanceCreateResponse)
|
| 362 |
+
async def seedance_create(body: SeedanceCreateBody):
|
| 363 |
+
"""Start a Seedance 2 video task. Maps 1–2 image URLs to first/last frame I2V; 3+ to reference_image_urls."""
|
| 364 |
+
dur = _clamp_seedance_duration(body.duration)
|
| 365 |
+
|
| 366 |
+
allowed_ratio = {"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}
|
| 367 |
+
ar = body.aspect_ratio if body.aspect_ratio in allowed_ratio else "9:16"
|
| 368 |
+
|
| 369 |
+
urls = [u for u in body.reference_image_urls if u and _valid_media_ref(u)]
|
| 370 |
+
if not urls:
|
| 371 |
+
raise HTTPException(
|
| 372 |
+
status_code=400,
|
| 373 |
+
detail="At least one image URL (http(s) or asset://) is required.",
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
prompt = _normalize_seedance_prompt(body.prompt)
|
| 377 |
+
|
| 378 |
+
public_url = get_public_base_url()
|
| 379 |
+
callback_url = f"{public_url}/api/seedance/callback"
|
| 380 |
+
|
| 381 |
+
inp = _seedance_input_from_urls(
|
| 382 |
+
urls,
|
| 383 |
+
prompt,
|
| 384 |
+
ar,
|
| 385 |
+
dur,
|
| 386 |
+
body.resolution,
|
| 387 |
+
body.generate_audio,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
payload = {
|
| 391 |
+
"model": body.model,
|
| 392 |
+
"input": inp,
|
| 393 |
+
"callBackUrl": callback_url,
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
kie_err: HTTPException | None = None
|
| 397 |
+
if os.getenv("KIE_API_KEY"):
|
| 398 |
+
try:
|
| 399 |
+
api_key = _kie_key()
|
| 400 |
+
task_id = await _kie_seedance_create_task(payload, api_key)
|
| 401 |
+
return SeedanceCreateResponse(taskId=task_id)
|
| 402 |
+
except HTTPException as e:
|
| 403 |
+
kie_err = e
|
| 404 |
+
logger.warning("KIE Seedance create failed, trying Replicate if configured: %s", e.detail)
|
| 405 |
+
if not os.getenv("KIE_API_KEY"):
|
| 406 |
+
logger.info("KIE_API_KEY missing; using Replicate for Seedance when token is set.")
|
| 407 |
+
|
| 408 |
+
rep = _replicate_token()
|
| 409 |
+
if not rep:
|
| 410 |
+
if kie_err:
|
| 411 |
+
raise kie_err
|
| 412 |
+
raise HTTPException(
|
| 413 |
+
status_code=500,
|
| 414 |
+
detail="Neither KIE_API_KEY nor REPLICATE_API_TOKEN is configured for Seedance.",
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
rep_slug = KIE_TO_REPLICATE_SEEDANCE_MODEL.get(body.model)
|
| 418 |
+
if not rep_slug:
|
| 419 |
+
raise HTTPException(status_code=500, detail="No Replicate model mapping for this Seedance variant.")
|
| 420 |
+
|
| 421 |
+
rep_input = _replicate_input_from_seedance_urls(
|
| 422 |
+
urls,
|
| 423 |
+
prompt,
|
| 424 |
+
ar,
|
| 425 |
+
dur,
|
| 426 |
+
body.resolution,
|
| 427 |
+
body.generate_audio,
|
| 428 |
+
public_url,
|
| 429 |
+
)
|
| 430 |
+
task_id = await _replicate_seedance_start(rep_slug, rep_input, rep)
|
| 431 |
+
if kie_err:
|
| 432 |
+
logger.info("Seedance task started via Replicate fallback (%s)", task_id)
|
| 433 |
+
return SeedanceCreateResponse(taskId=task_id)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
@router.get("/seedance/status/{task_id}")
|
| 437 |
+
async def seedance_status(task_id: str):
|
| 438 |
+
"""Poll KIE jobs/recordInfo or Replicate prediction; normalize for the browser."""
|
| 439 |
+
cached = seedance_results.get(task_id)
|
| 440 |
+
if cached:
|
| 441 |
+
return cached
|
| 442 |
+
|
| 443 |
+
if task_id.startswith(REPLICATE_TASK_PREFIX):
|
| 444 |
+
token = _replicate_token()
|
| 445 |
+
if not token:
|
| 446 |
+
raise HTTPException(status_code=500, detail="REPLICATE_API_TOKEN not configured on server.")
|
| 447 |
+
pred_id = task_id[len(REPLICATE_TASK_PREFIX) :]
|
| 448 |
+
raw = await _replicate_prediction_fetch(pred_id, token)
|
| 449 |
+
out = _replicate_prediction_to_job_shape(raw)
|
| 450 |
+
if out["state"] in {"success", "fail"}:
|
| 451 |
+
seedance_results[task_id] = {
|
| 452 |
+
**out,
|
| 453 |
+
"timestamp": datetime.now().timestamp(),
|
| 454 |
+
}
|
| 455 |
+
_cleanup_old_seedance_results()
|
| 456 |
+
return out
|
| 457 |
+
|
| 458 |
+
api_key = _kie_key()
|
| 459 |
+
try:
|
| 460 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 461 |
+
r = await client.get(
|
| 462 |
+
f"{KIE_API_BASE}/api/v1/jobs/recordInfo",
|
| 463 |
+
params={"taskId": task_id},
|
| 464 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 465 |
+
)
|
| 466 |
+
except httpx.RequestError as e:
|
| 467 |
+
raise HTTPException(status_code=502, detail=f"KIE request error: {e}") from e
|
| 468 |
+
|
| 469 |
+
if r.status_code != 200:
|
| 470 |
+
raise HTTPException(status_code=r.status_code, detail="KIE recordInfo failed")
|
| 471 |
+
|
| 472 |
+
body = r.json()
|
| 473 |
+
if body.get("code") != 200:
|
| 474 |
+
raise HTTPException(
|
| 475 |
+
status_code=body.get("code", 502),
|
| 476 |
+
detail=body.get("msg", "KIE recordInfo error"),
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
d = body.get("data") or {}
|
| 480 |
+
state = d.get("state")
|
| 481 |
+
out: dict = {
|
| 482 |
+
"state": state,
|
| 483 |
+
"url": None,
|
| 484 |
+
"failMsg": d.get("failMsg"),
|
| 485 |
+
"failCode": d.get("failCode"),
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
if state == "success" and d.get("resultJson"):
|
| 489 |
+
try:
|
| 490 |
+
rj = json.loads(d["resultJson"])
|
| 491 |
+
urls = rj.get("resultUrls") or []
|
| 492 |
+
if urls:
|
| 493 |
+
out["url"] = urls[0]
|
| 494 |
+
except (json.JSONDecodeError, TypeError, KeyError):
|
| 495 |
+
pass
|
| 496 |
+
|
| 497 |
+
if out["state"] in {"success", "fail"}:
|
| 498 |
+
seedance_results[task_id] = {
|
| 499 |
+
**out,
|
| 500 |
+
"timestamp": datetime.now().timestamp(),
|
| 501 |
+
}
|
| 502 |
+
_cleanup_old_seedance_results()
|
| 503 |
+
return out
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
@router.get("/seedance/events/{task_id}")
|
| 507 |
+
async def seedance_events(task_id: str):
|
| 508 |
+
"""Server-Sent Events stream for callback-driven Seedance status."""
|
| 509 |
+
|
| 510 |
+
async def event_generator():
|
| 511 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 512 |
+
seedance_sse_clients[task_id] = queue
|
| 513 |
+
try:
|
| 514 |
+
existing = seedance_results.get(task_id)
|
| 515 |
+
if existing:
|
| 516 |
+
yield f"data: {json.dumps(existing)}\n\n"
|
| 517 |
+
return
|
| 518 |
+
|
| 519 |
+
if task_id.startswith(REPLICATE_TASK_PREFIX):
|
| 520 |
+
token = _replicate_token()
|
| 521 |
+
if not token:
|
| 522 |
+
yield f"data: {json.dumps({'state': 'fail', 'url': None, 'failMsg': 'REPLICATE_API_TOKEN not configured'})}\n\n"
|
| 523 |
+
return
|
| 524 |
+
pred_id = task_id[len(REPLICATE_TASK_PREFIX) :]
|
| 525 |
+
while True:
|
| 526 |
+
raw = await _replicate_prediction_fetch(pred_id, token)
|
| 527 |
+
out = _replicate_prediction_to_job_shape(raw)
|
| 528 |
+
if out["state"] in {"success", "fail"}:
|
| 529 |
+
seedance_results[task_id] = {
|
| 530 |
+
**out,
|
| 531 |
+
"timestamp": datetime.now().timestamp(),
|
| 532 |
+
}
|
| 533 |
+
_cleanup_old_seedance_results()
|
| 534 |
+
yield f"data: {json.dumps(out)}\n\n"
|
| 535 |
+
return
|
| 536 |
+
await asyncio.sleep(2.0)
|
| 537 |
+
|
| 538 |
+
while True:
|
| 539 |
+
data = await queue.get()
|
| 540 |
+
yield f"data: {json.dumps(data)}\n\n"
|
| 541 |
+
if data.get("state") in {"success", "fail"}:
|
| 542 |
+
return
|
| 543 |
+
except asyncio.CancelledError:
|
| 544 |
+
return
|
| 545 |
+
finally:
|
| 546 |
+
if task_id in seedance_sse_clients:
|
| 547 |
+
del seedance_sse_clients[task_id]
|
| 548 |
+
|
| 549 |
+
return StreamingResponse(
|
| 550 |
+
event_generator(),
|
| 551 |
+
media_type="text/event-stream",
|
| 552 |
+
headers={
|
| 553 |
+
"Cache-Control": "no-cache",
|
| 554 |
+
"Connection": "keep-alive",
|
| 555 |
+
},
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
@router.post("/seedance/callback")
|
| 560 |
+
async def seedance_callback(request: Request):
|
| 561 |
+
"""
|
| 562 |
+
KIE callback endpoint; updates in-memory result and notifies SSE clients.
|
| 563 |
+
"""
|
| 564 |
+
try:
|
| 565 |
+
payload = await request.json()
|
| 566 |
+
if not isinstance(payload, dict):
|
| 567 |
+
payload = {}
|
| 568 |
+
except Exception:
|
| 569 |
+
payload = {}
|
| 570 |
+
|
| 571 |
+
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
| 572 |
+
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
| 573 |
+
task_id = info.get("taskId") or data.get("taskId")
|
| 574 |
+
|
| 575 |
+
if task_id:
|
| 576 |
+
out = _extract_seedance_callback_state(payload)
|
| 577 |
+
seedance_results[task_id] = {
|
| 578 |
+
**out,
|
| 579 |
+
"timestamp": datetime.now().timestamp(),
|
| 580 |
+
}
|
| 581 |
+
await _send_seedance_sse_event(task_id, out)
|
| 582 |
+
_cleanup_old_seedance_results()
|
| 583 |
+
|
| 584 |
+
return JSONResponse(status_code=200, content={"code": 200, "msg": "ok"})
|
backend/api/showcase_prompts.py
ADDED
|
@@ -0,0 +1,1223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cinematic product showcase: stream a shot plan as Veo-compatible segments.
|
| 3 |
+
Uses OpenAI when OPENAI_API_KEY is set; otherwise uses a built-in template.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import base64
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import uuid
|
| 13 |
+
from typing import Any, List, Optional
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
| 17 |
+
from fastapi.responses import StreamingResponse
|
| 18 |
+
from pydantic import BaseModel, Field
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
DEFAULT_MODEL = os.getenv("SHOWCASE_PROMPT_MODEL", "gpt-4o")
|
| 23 |
+
|
| 24 |
+
# Creative concepts: distinct shot DNA for template + OpenAI planning.
|
| 25 |
+
_CONCEPTS: dict[str, dict[str, Any]] = {
|
| 26 |
+
"luxury_studio": {
|
| 27 |
+
"label": "Luxury studio launch",
|
| 28 |
+
"mood_tag": "controlled studio, soft bloom, premium reflections",
|
| 29 |
+
"director": (
|
| 30 |
+
"Premium packshot and launch-film grammar: slow dolly, pedestal or tabletop, "
|
| 31 |
+
"whisper VO optional, negative space, reflections choreographed. Restraint over hype."
|
| 32 |
+
),
|
| 33 |
+
"location": "controlled studio / pedestal tabletop",
|
| 34 |
+
"voice_matching": "Optional restrained VO; otherwise silent cinematic bed",
|
| 35 |
+
"rhythm": "slow commercial pacing",
|
| 36 |
+
"transition": "invisible cuts; consistent color grade",
|
| 37 |
+
"beats": [
|
| 38 |
+
(
|
| 39 |
+
"Slow push-in on hero packshot; shallow depth of field; optional subtle haze",
|
| 40 |
+
"VO or silent: establish iconic hero framing",
|
| 41 |
+
),
|
| 42 |
+
(
|
| 43 |
+
"Macro glide across materials, seams, and signature details",
|
| 44 |
+
"Whisper VO: emphasize craftsmanship",
|
| 45 |
+
),
|
| 46 |
+
(
|
| 47 |
+
"Orbital or ¾ arc; reflections and rim read as luxury cues",
|
| 48 |
+
"Build desire through form and light",
|
| 49 |
+
),
|
| 50 |
+
(
|
| 51 |
+
"Top-down flat-lay with minimal props; brand-forward negative space",
|
| 52 |
+
"Context without clutter",
|
| 53 |
+
),
|
| 54 |
+
(
|
| 55 |
+
"Silhouette beat with rim light; bold graphic composition",
|
| 56 |
+
"Hold for logo-safe end board",
|
| 57 |
+
),
|
| 58 |
+
(
|
| 59 |
+
"Wide curated lifestyle tableau; soft daylight, calm payoff",
|
| 60 |
+
"Emotional close; premium calm",
|
| 61 |
+
),
|
| 62 |
+
],
|
| 63 |
+
},
|
| 64 |
+
"ugc_authentic": {
|
| 65 |
+
"label": "UGC / social authentic",
|
| 66 |
+
"mood_tag": "handheld creator realism, warm practicals, real room",
|
| 67 |
+
"director": (
|
| 68 |
+
"Short-form authentic energy: slight handheld, ring or window light, desk or counter. "
|
| 69 |
+
"Hooks fast, shows real use, avoids fake polish. Feels like TikTok/Reels love-letter."
|
| 70 |
+
),
|
| 71 |
+
"location": "creator desk, kitchen counter, or bedroom nook",
|
| 72 |
+
"voice_matching": "Casual spoken hook; conversational, not announcer",
|
| 73 |
+
"rhythm": "punchy short-form; quick reframes",
|
| 74 |
+
"transition": "jump cuts ok; keep product identity consistent",
|
| 75 |
+
"beats": [
|
| 76 |
+
(
|
| 77 |
+
"Handheld close-up opening hook; product fills frame; natural micro-shake",
|
| 78 |
+
"Quick spoken hook — why this product hits different",
|
| 79 |
+
),
|
| 80 |
+
(
|
| 81 |
+
"Over-shoulder desk POV; hands interact; daylight spill",
|
| 82 |
+
"Show real setup or unbox beat in one breath",
|
| 83 |
+
),
|
| 84 |
+
(
|
| 85 |
+
"Macro proof shot: texture, hinge, button, or pour — still handheld",
|
| 86 |
+
"Casual VO: the detail that sold you",
|
| 87 |
+
),
|
| 88 |
+
(
|
| 89 |
+
"Wide creator space; product in lived-in context; plants, mug, cables ok",
|
| 90 |
+
"Relatable payoff; not a sterile set",
|
| 91 |
+
),
|
| 92 |
+
(
|
| 93 |
+
"Low angle hero on table; warm practical rim; quick push with wrist",
|
| 94 |
+
"Energy lift before outro",
|
| 95 |
+
),
|
| 96 |
+
(
|
| 97 |
+
"Faceless hold-to-camera packshot; soft smile energy implied",
|
| 98 |
+
"CTA-friendly final beat; stay human",
|
| 99 |
+
),
|
| 100 |
+
],
|
| 101 |
+
},
|
| 102 |
+
"tech_minimal": {
|
| 103 |
+
"label": "Tech / minimal dopamine",
|
| 104 |
+
"mood_tag": "clean void, hard edge light, precision motion",
|
| 105 |
+
"director": (
|
| 106 |
+
"Sci-fi adjacent minimalism: white/gunmetal void, crisp edges, precise slides. "
|
| 107 |
+
"Feels like flagship device film — calm confidence, no lifestyle clutter."
|
| 108 |
+
),
|
| 109 |
+
"location": "minimal void set or seamless cyclorama",
|
| 110 |
+
"voice_matching": "Sparse tech VO or silent; measured pacing",
|
| 111 |
+
"rhythm": "locked tempo; mechanical precision",
|
| 112 |
+
"transition": "match cuts on geometry; consistent specular language",
|
| 113 |
+
"beats": [
|
| 114 |
+
(
|
| 115 |
+
"Dead-on hero with hard edge key; product floats on void",
|
| 116 |
+
"Silent establish: silhouette reads as engineering",
|
| 117 |
+
),
|
| 118 |
+
(
|
| 119 |
+
"Linear slide parallel to product face; parallax on ports or lens ring",
|
| 120 |
+
"Highlight precision tolerances",
|
| 121 |
+
),
|
| 122 |
+
(
|
| 123 |
+
"Macro on interface, LED, or texture grid; single specular rake",
|
| 124 |
+
"VO optional: one sharp benefit",
|
| 125 |
+
),
|
| 126 |
+
(
|
| 127 |
+
"Top-down symmetry break: product rotates 15° into perfect alignment",
|
| 128 |
+
"Satisfying ‘snap’ moment",
|
| 129 |
+
),
|
| 130 |
+
(
|
| 131 |
+
"Low fog or haze pass optional; rim only; graphic shadow",
|
| 132 |
+
"Tension before resolve",
|
| 133 |
+
),
|
| 134 |
+
(
|
| 135 |
+
"Final lockup: product centered, breathing room for UI or price supers",
|
| 136 |
+
"Calm authority",
|
| 137 |
+
),
|
| 138 |
+
],
|
| 139 |
+
},
|
| 140 |
+
"lifestyle_natural": {
|
| 141 |
+
"label": "Lifestyle / natural daylight",
|
| 142 |
+
"mood_tag": "morning sun, wood and linen, slow living",
|
| 143 |
+
"director": (
|
| 144 |
+
"Natural lifestyle: real interiors, soft sun paths, slow living. "
|
| 145 |
+
"Product feels lived-with — editorial home story, not a soundstage."
|
| 146 |
+
),
|
| 147 |
+
"location": "sunlit home: kitchen island, reading nook, or bathroom vanity",
|
| 148 |
+
"voice_matching": "Warm intimate VO or ambient-forward",
|
| 149 |
+
"rhythm": "unhurried; breath between moves",
|
| 150 |
+
"transition": "time-of-day consistent; gentle grade",
|
| 151 |
+
"beats": [
|
| 152 |
+
(
|
| 153 |
+
"Wide morning table; long sun streak; product in daily ritual context",
|
| 154 |
+
"VO: quiet promise of routine upgraded",
|
| 155 |
+
),
|
| 156 |
+
(
|
| 157 |
+
"Hands-in-frame interaction; shallow depth; steam or pour optional if truthful",
|
| 158 |
+
"Sensory cues without gimmick",
|
| 159 |
+
),
|
| 160 |
+
(
|
| 161 |
+
"Window-side profile; soft bounce; product catches real highlight",
|
| 162 |
+
"Let natural light sculpt form",
|
| 163 |
+
),
|
| 164 |
+
(
|
| 165 |
+
"Slow walk-by with camera on slider; background falls away",
|
| 166 |
+
"Lifestyle scale without losing product read",
|
| 167 |
+
),
|
| 168 |
+
(
|
| 169 |
+
"Detail insert on material next to organic props (fruit, book, fabric)",
|
| 170 |
+
"Tactile contrast",
|
| 171 |
+
),
|
| 172 |
+
(
|
| 173 |
+
"Golden-hour wide; product small-in-frame then gentle push to hero",
|
| 174 |
+
"Emotional landing",
|
| 175 |
+
),
|
| 176 |
+
],
|
| 177 |
+
},
|
| 178 |
+
"bold_editorial": {
|
| 179 |
+
"label": "Bold / color editorial",
|
| 180 |
+
"mood_tag": "gelled color, graphic shadows, high fashion energy",
|
| 181 |
+
"director": (
|
| 182 |
+
"High-impact editorial: colored gels, graphic shadows, snap energy. "
|
| 183 |
+
"Still product-true — think campaign poster in motion."
|
| 184 |
+
),
|
| 185 |
+
"location": "studio set with bold color fields or geometric props",
|
| 186 |
+
"voice_matching": "Confident VO with attitude or music-forward",
|
| 187 |
+
"rhythm": "syncopated; beat-driven if VO sparse",
|
| 188 |
+
"transition": "hard cuts welcome; keep product silhouette readable",
|
| 189 |
+
"beats": [
|
| 190 |
+
(
|
| 191 |
+
"Silhouette against gel wash; product revealed by moving flag",
|
| 192 |
+
"Hook with shape, not text",
|
| 193 |
+
),
|
| 194 |
+
(
|
| 195 |
+
"Low angle with strong key; saturated bounce card",
|
| 196 |
+
"Attitude beat",
|
| 197 |
+
),
|
| 198 |
+
(
|
| 199 |
+
"Macro with color contrast: complementary gel on background",
|
| 200 |
+
"Make material pop",
|
| 201 |
+
),
|
| 202 |
+
(
|
| 203 |
+
"Dutch-lite framing optional; dynamic diagonal composition",
|
| 204 |
+
"Editorial risk within brand safety",
|
| 205 |
+
),
|
| 206 |
+
(
|
| 207 |
+
"Strobe-like pulse of light (single source step) — tasteful, not club",
|
| 208 |
+
"Peak energy",
|
| 209 |
+
),
|
| 210 |
+
(
|
| 211 |
+
"Clean hero resolve on neutral for logo legibility",
|
| 212 |
+
"Campaign poster frame",
|
| 213 |
+
),
|
| 214 |
+
],
|
| 215 |
+
},
|
| 216 |
+
"unboxing_asmr": {
|
| 217 |
+
"label": "Unboxing / desk ASMR",
|
| 218 |
+
"mood_tag": "top-down satisfaction, slow peel, whisper-quiet motion",
|
| 219 |
+
"director": (
|
| 220 |
+
"Unboxing satisfaction: top-down desk, slow pulls, crisp foley-friendly motion. "
|
| 221 |
+
"Whisper VO or silent; emphasize packaging choreography and product emergence."
|
| 222 |
+
),
|
| 223 |
+
"location": "clean desk top-down; mat or paper texture",
|
| 224 |
+
"voice_matching": "Whisper ASMR VO or silent; no shouty announcer",
|
| 225 |
+
"rhythm": "elongated holds; micro pauses on satisfying moments",
|
| 226 |
+
"transition": "continuous tabletop geography",
|
| 227 |
+
"beats": [
|
| 228 |
+
(
|
| 229 |
+
"Top-down: hands enter; box or sleeve centered; slow controlled slide",
|
| 230 |
+
"Anticipation; no rush",
|
| 231 |
+
),
|
| 232 |
+
(
|
| 233 |
+
"Peel film or lift lid; reveal inner tray; macro on texture",
|
| 234 |
+
"ASMR-forward motion",
|
| 235 |
+
),
|
| 236 |
+
(
|
| 237 |
+
"Lift product from nest; rotate once for silhouette read",
|
| 238 |
+
"First full hero reveal",
|
| 239 |
+
),
|
| 240 |
+
(
|
| 241 |
+
"Lay accessories in row; knolling grammar optional",
|
| 242 |
+
"Completeness story",
|
| 243 |
+
),
|
| 244 |
+
(
|
| 245 |
+
"Slow push on logo or hero detail; shallow depth",
|
| 246 |
+
"Proof of quality",
|
| 247 |
+
),
|
| 248 |
+
(
|
| 249 |
+
"Final flat lay: product + key pieces; hands exit frame clean",
|
| 250 |
+
"Thumbnail-friendly end",
|
| 251 |
+
),
|
| 252 |
+
],
|
| 253 |
+
},
|
| 254 |
+
"high_energy_sports": {
|
| 255 |
+
"label": "High-energy sports",
|
| 256 |
+
"mood_tag": "kinetic motion, impact lighting, athletic momentum",
|
| 257 |
+
"director": (
|
| 258 |
+
"Performance ad language: dynamic camera, fast reveals, hard accents, and purposeful speed. "
|
| 259 |
+
"Product remains readable while action sells intensity."
|
| 260 |
+
),
|
| 261 |
+
"location": "training floor, outdoor court, or industrial motion set",
|
| 262 |
+
"voice_matching": "Punchy VO with motivational cadence",
|
| 263 |
+
"rhythm": "fast and percussive",
|
| 264 |
+
"transition": "hard cuts and whip-aligned moves",
|
| 265 |
+
"beats": [
|
| 266 |
+
(
|
| 267 |
+
"Explosive hero reveal with quick push and lateral sweep",
|
| 268 |
+
"Hook instantly with energy and intent",
|
| 269 |
+
),
|
| 270 |
+
(
|
| 271 |
+
"Tracking move alongside product in active use context",
|
| 272 |
+
"Demonstrate performance under movement",
|
| 273 |
+
),
|
| 274 |
+
(
|
| 275 |
+
"Macro impact detail on grip, texture, or mechanism",
|
| 276 |
+
"Call out functional advantage",
|
| 277 |
+
),
|
| 278 |
+
(
|
| 279 |
+
"Low-angle speed pass with streaking background cues",
|
| 280 |
+
"Amplify power and acceleration",
|
| 281 |
+
),
|
| 282 |
+
(
|
| 283 |
+
"Freeze-like resolve beat with sharp rim and bold silhouette",
|
| 284 |
+
"Own the frame with confidence",
|
| 285 |
+
),
|
| 286 |
+
(
|
| 287 |
+
"Final hero lockup with breathing room for CTA",
|
| 288 |
+
"Strong finish with athletic authority",
|
| 289 |
+
),
|
| 290 |
+
],
|
| 291 |
+
},
|
| 292 |
+
"moody_cinematic_noir": {
|
| 293 |
+
"label": "Moody cinematic noir",
|
| 294 |
+
"mood_tag": "deep contrast, practical pools, dramatic shadow play",
|
| 295 |
+
"director": (
|
| 296 |
+
"Neo-noir product storytelling: chiaroscuro lighting, intentional darkness, and controlled reveals. "
|
| 297 |
+
"Tension first, payoff second, always preserving product fidelity."
|
| 298 |
+
),
|
| 299 |
+
"location": "night interior with practical lamps, rain-streaked windows, or dark studio",
|
| 300 |
+
"voice_matching": "Low intimate VO or silence with atmospheric bed",
|
| 301 |
+
"rhythm": "measured suspense",
|
| 302 |
+
"transition": "shadow-led continuity and slow dissolves or soft cuts",
|
| 303 |
+
"beats": [
|
| 304 |
+
(
|
| 305 |
+
"Product emerges from shadow into a narrow key-light path",
|
| 306 |
+
"Tease form before full reveal",
|
| 307 |
+
),
|
| 308 |
+
(
|
| 309 |
+
"Slow dolly across reflective surfaces and edge highlights",
|
| 310 |
+
"Build intrigue through texture",
|
| 311 |
+
),
|
| 312 |
+
(
|
| 313 |
+
"Macro insert on signature detail with selective focus roll",
|
| 314 |
+
"Reward attention with craft cues",
|
| 315 |
+
),
|
| 316 |
+
(
|
| 317 |
+
"Silhouette profile against practical backlight",
|
| 318 |
+
"Hold tension with clean geometry",
|
| 319 |
+
),
|
| 320 |
+
(
|
| 321 |
+
"Subtle orbit as highlights travel across contours",
|
| 322 |
+
"Shift from mystery to confidence",
|
| 323 |
+
),
|
| 324 |
+
(
|
| 325 |
+
"Final centered hero in controlled darkness",
|
| 326 |
+
"Memorable noir resolve",
|
| 327 |
+
),
|
| 328 |
+
],
|
| 329 |
+
},
|
| 330 |
+
"playful_stopmotion_style": {
|
| 331 |
+
"label": "Playful stop-motion style",
|
| 332 |
+
"mood_tag": "quirky tabletop, handcrafted charm, rhythmic object motion",
|
| 333 |
+
"director": (
|
| 334 |
+
"Whimsical tabletop storytelling inspired by stop-motion cadence. "
|
| 335 |
+
"Products move with intentional beats, graphic compositions, and delightful transitions."
|
| 336 |
+
),
|
| 337 |
+
"location": "colorful tabletop set with paper, props, and clean mini-scenes",
|
| 338 |
+
"voice_matching": "Bright, friendly VO or playful sound-forward pacing",
|
| 339 |
+
"rhythm": "bouncy and rhythmic",
|
| 340 |
+
"transition": "match cuts via prop movement and shape continuity",
|
| 341 |
+
"beats": [
|
| 342 |
+
(
|
| 343 |
+
"Top-down hero pop-in with prop elements snapping into place",
|
| 344 |
+
"Immediate playful hook",
|
| 345 |
+
),
|
| 346 |
+
(
|
| 347 |
+
"Stepwise lateral moves revealing key product sides",
|
| 348 |
+
"Show form with toy-like precision",
|
| 349 |
+
),
|
| 350 |
+
(
|
| 351 |
+
"Macro detail punctuated by tiny prop choreography",
|
| 352 |
+
"Turn features into moments of delight",
|
| 353 |
+
),
|
| 354 |
+
(
|
| 355 |
+
"Mini-scene swap with product centered as anchor",
|
| 356 |
+
"Context changes while identity stays constant",
|
| 357 |
+
),
|
| 358 |
+
(
|
| 359 |
+
"Color-block background shift synced with product rotation",
|
| 360 |
+
"Peak visual fun",
|
| 361 |
+
),
|
| 362 |
+
(
|
| 363 |
+
"Clean final tableau with product and hero prop accents",
|
| 364 |
+
"Cheerful, shareable end frame",
|
| 365 |
+
),
|
| 366 |
+
],
|
| 367 |
+
},
|
| 368 |
+
"nature_outdoor_adventure": {
|
| 369 |
+
"label": "Nature / outdoor adventure",
|
| 370 |
+
"mood_tag": "golden trails, fresh air texture, expansive outdoor scale",
|
| 371 |
+
"director": (
|
| 372 |
+
"Adventure brand language with natural elements: sweeping vistas, tactile details, and grounded utility. "
|
| 373 |
+
"Product feels capable, dependable, and integrated with real outdoor moments."
|
| 374 |
+
),
|
| 375 |
+
"location": "mountain trail, lakeside camp, or forest clearing at golden hour",
|
| 376 |
+
"voice_matching": "Grounded VO with aspirational outdoor tone",
|
| 377 |
+
"rhythm": "steady forward momentum",
|
| 378 |
+
"transition": "match cuts on motion and horizon lines",
|
| 379 |
+
"beats": [
|
| 380 |
+
(
|
| 381 |
+
"Wide establishing shot with product foregrounded against open landscape",
|
| 382 |
+
"Set scale and purpose in one frame",
|
| 383 |
+
),
|
| 384 |
+
(
|
| 385 |
+
"Tracking side move through natural texture: rock, wood, or trail dust",
|
| 386 |
+
"Show rugged utility and real-world context",
|
| 387 |
+
),
|
| 388 |
+
(
|
| 389 |
+
"Macro detail with moisture, grain, or weathered surfaces nearby",
|
| 390 |
+
"Signal durability and craftsmanship",
|
| 391 |
+
),
|
| 392 |
+
(
|
| 393 |
+
"Hands-in-use moment framed by natural backlight",
|
| 394 |
+
"Demonstrate intuitive use in the field",
|
| 395 |
+
),
|
| 396 |
+
(
|
| 397 |
+
"Hero profile on elevated surface with wind/light movement",
|
| 398 |
+
"Build emotional ownership and capability",
|
| 399 |
+
),
|
| 400 |
+
(
|
| 401 |
+
"Sunset resolve with clean product lockup and breathing room",
|
| 402 |
+
"End on calm confidence and adventure payoff",
|
| 403 |
+
),
|
| 404 |
+
],
|
| 405 |
+
},
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _normalize_concept(raw: Optional[str]) -> str:
|
| 410 |
+
k = (raw or "").strip().lower().replace("-", "_")
|
| 411 |
+
if k in _CONCEPTS:
|
| 412 |
+
return k
|
| 413 |
+
return "luxury_studio"
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _concept_meta(concept_key: str) -> dict[str, Any]:
|
| 417 |
+
return _CONCEPTS.get(_normalize_concept(concept_key), _CONCEPTS["luxury_studio"])
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _heuristic_seconds_per_segment(
|
| 421 |
+
*,
|
| 422 |
+
concept_key: str,
|
| 423 |
+
product_name: str,
|
| 424 |
+
tagline: str,
|
| 425 |
+
mood: str,
|
| 426 |
+
features: str,
|
| 427 |
+
shot_count: int,
|
| 428 |
+
) -> int:
|
| 429 |
+
"""
|
| 430 |
+
Pick 4/6/8s pacing from concept DNA + brief richness.
|
| 431 |
+
This is used when caller does not force a value.
|
| 432 |
+
"""
|
| 433 |
+
concept_defaults = {
|
| 434 |
+
"luxury_studio": 8,
|
| 435 |
+
"ugc_authentic": 4,
|
| 436 |
+
"tech_minimal": 6,
|
| 437 |
+
"lifestyle_natural": 6,
|
| 438 |
+
"bold_editorial": 4,
|
| 439 |
+
"unboxing_asmr": 6,
|
| 440 |
+
"high_energy_sports": 4,
|
| 441 |
+
"moody_cinematic_noir": 8,
|
| 442 |
+
"playful_stopmotion_style": 4,
|
| 443 |
+
"nature_outdoor_adventure": 6,
|
| 444 |
+
}
|
| 445 |
+
base = concept_defaults.get(_normalize_concept(concept_key), 6)
|
| 446 |
+
|
| 447 |
+
richness_text = " ".join(
|
| 448 |
+
[
|
| 449 |
+
(product_name or "").strip(),
|
| 450 |
+
(tagline or "").strip(),
|
| 451 |
+
(mood or "").strip(),
|
| 452 |
+
(features or "").strip(),
|
| 453 |
+
]
|
| 454 |
+
).strip()
|
| 455 |
+
words = len([w for w in richness_text.split() if w])
|
| 456 |
+
|
| 457 |
+
# Richer briefs and fewer shots can support longer per-shot beats.
|
| 458 |
+
if words >= 45:
|
| 459 |
+
base += 2
|
| 460 |
+
elif words <= 12:
|
| 461 |
+
base -= 2
|
| 462 |
+
|
| 463 |
+
if shot_count <= 3:
|
| 464 |
+
base += 2
|
| 465 |
+
elif shot_count >= 6:
|
| 466 |
+
base -= 2
|
| 467 |
+
|
| 468 |
+
if base <= 4:
|
| 469 |
+
return 4
|
| 470 |
+
if base <= 6:
|
| 471 |
+
return 6
|
| 472 |
+
return 8
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _parse_seconds_candidate(v: Any) -> Optional[int]:
|
| 476 |
+
try:
|
| 477 |
+
n = int(v)
|
| 478 |
+
except Exception:
|
| 479 |
+
return None
|
| 480 |
+
return n if n in (4, 6, 8) else None
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _llm_seconds_per_segment(
|
| 484 |
+
*,
|
| 485 |
+
client: Any,
|
| 486 |
+
model: str,
|
| 487 |
+
concept_key: str,
|
| 488 |
+
product_name: str,
|
| 489 |
+
tagline: str,
|
| 490 |
+
mood: str,
|
| 491 |
+
features: str,
|
| 492 |
+
shot_count: int,
|
| 493 |
+
fallback_seconds: int,
|
| 494 |
+
) -> int:
|
| 495 |
+
"""
|
| 496 |
+
Ask the planner LLM to choose 4/6/8 seconds for pacing.
|
| 497 |
+
Falls back to the heuristic-derived value if parsing fails.
|
| 498 |
+
"""
|
| 499 |
+
c = _concept_meta(concept_key)
|
| 500 |
+
system = (
|
| 501 |
+
"You are a short-form ad pacing director. "
|
| 502 |
+
"Choose seconds_per_segment for a product showcase. "
|
| 503 |
+
"Allowed values are ONLY 4, 6, or 8. "
|
| 504 |
+
"Return strict JSON: {\"seconds_per_segment\": 4|6|8, \"reason\": \"...\"}."
|
| 505 |
+
)
|
| 506 |
+
user = json.dumps(
|
| 507 |
+
{
|
| 508 |
+
"task": "choose_seconds_per_segment",
|
| 509 |
+
"instruction": "Choose one value balancing clarity, visual richness, and social retention.",
|
| 510 |
+
"allowed_values": [4, 6, 8],
|
| 511 |
+
"concept": {
|
| 512 |
+
"id": _normalize_concept(concept_key),
|
| 513 |
+
"label": c["label"],
|
| 514 |
+
"rhythm": c["rhythm"],
|
| 515 |
+
},
|
| 516 |
+
"brief": {
|
| 517 |
+
"product_name": product_name,
|
| 518 |
+
"tagline": tagline or "n/a",
|
| 519 |
+
"mood": mood or "n/a",
|
| 520 |
+
"features": features or "n/a",
|
| 521 |
+
"shot_count": shot_count,
|
| 522 |
+
},
|
| 523 |
+
"response_schema": {
|
| 524 |
+
"seconds_per_segment": "4|6|8",
|
| 525 |
+
"reason": "string",
|
| 526 |
+
},
|
| 527 |
+
}
|
| 528 |
+
)
|
| 529 |
+
try:
|
| 530 |
+
resp = client.chat.completions.create(
|
| 531 |
+
model=model,
|
| 532 |
+
messages=[
|
| 533 |
+
{"role": "system", "content": system},
|
| 534 |
+
{"role": "user", "content": user},
|
| 535 |
+
],
|
| 536 |
+
response_format={"type": "json_object"},
|
| 537 |
+
temperature=0.2,
|
| 538 |
+
)
|
| 539 |
+
txt = resp.choices[0].message.content or "{}"
|
| 540 |
+
data = json.loads(txt)
|
| 541 |
+
pick = _parse_seconds_candidate(data.get("seconds_per_segment"))
|
| 542 |
+
return pick if pick is not None else fallback_seconds
|
| 543 |
+
except Exception:
|
| 544 |
+
return fallback_seconds
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def _continuity_template() -> dict:
|
| 548 |
+
return {
|
| 549 |
+
"start_position": "product centered, stable hero framing",
|
| 550 |
+
"end_position": "same product identity, refined end pose for cut",
|
| 551 |
+
"start_expression": "premium, confident product presence",
|
| 552 |
+
"end_expression": "elegant pause before next beat",
|
| 553 |
+
"start_gesture": "subtle light roll on hero surfaces",
|
| 554 |
+
"end_gesture": "micro shift emphasizing silhouette",
|
| 555 |
+
"location_status": "continuous studio-grade set",
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def _scene_for_shot(mood: str, beat: str) -> dict:
|
| 560 |
+
return {
|
| 561 |
+
"environment": f"minimal cinematic set, {mood} grade, high-end commercial",
|
| 562 |
+
"camera_position": "tripod or slow dolly, professional commercial",
|
| 563 |
+
"camera_movement": beat,
|
| 564 |
+
"lighting_state": "soft key, crisp rim, controlled reflections on product",
|
| 565 |
+
"background_elements": "negative space, subtle gradient or matte surface",
|
| 566 |
+
"spatial_relationships": "product dominates frame, clean horizon",
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def _actions_for_duration(seconds: int) -> dict[str, str]:
|
| 571 |
+
if seconds <= 4:
|
| 572 |
+
return {
|
| 573 |
+
"0:00-0:02": "Lock exposure; begin ultra-slow push",
|
| 574 |
+
"0:02-0:04": "Hold hero; emphasize material read",
|
| 575 |
+
}
|
| 576 |
+
if seconds <= 6:
|
| 577 |
+
return {
|
| 578 |
+
"0:00-0:02": "Establish hero; gentle parallax",
|
| 579 |
+
"0:02-0:04": "Macro-friendly move toward detail",
|
| 580 |
+
"0:04-0:06": "Settle on iconic silhouette",
|
| 581 |
+
}
|
| 582 |
+
return {
|
| 583 |
+
"0:00-0:02": "Establish premium hero framing",
|
| 584 |
+
"0:02-0:04": "Slow move to reveal depth and texture",
|
| 585 |
+
"0:04-0:06": "Accent reflections; controlled speculars",
|
| 586 |
+
"0:06-0:08": "Resolve on clean iconic pose for edit",
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def _bucket_count_for_seconds(seconds: int) -> int:
|
| 591 |
+
if seconds <= 4:
|
| 592 |
+
return 2
|
| 593 |
+
if seconds <= 6:
|
| 594 |
+
return 3
|
| 595 |
+
return 4
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def _concept_arc(concept_key: str, shot_count: int) -> list[dict[str, str]]:
|
| 599 |
+
c = _concept_meta(concept_key)
|
| 600 |
+
beats: list[tuple[str, str]] = list(c["beats"])
|
| 601 |
+
n = max(3, min(shot_count, len(beats)))
|
| 602 |
+
selected = beats[:n]
|
| 603 |
+
return [
|
| 604 |
+
{
|
| 605 |
+
"segment_number": i,
|
| 606 |
+
"camera_beat": beat,
|
| 607 |
+
"story_intent": intent,
|
| 608 |
+
}
|
| 609 |
+
for i, (beat, intent) in enumerate(selected, start=1)
|
| 610 |
+
]
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _segment_has_substance(seg: dict) -> bool:
|
| 614 |
+
scene = seg.get("scene_continuity") or {}
|
| 615 |
+
timeline = seg.get("action_timeline") or {}
|
| 616 |
+
camera_movement = str(scene.get("camera_movement") or "").strip()
|
| 617 |
+
dialogue = str(timeline.get("dialogue") or "").strip()
|
| 618 |
+
actions = timeline.get("synchronized_actions")
|
| 619 |
+
if not camera_movement:
|
| 620 |
+
return False
|
| 621 |
+
if not dialogue:
|
| 622 |
+
return False
|
| 623 |
+
if not isinstance(actions, dict) or not actions:
|
| 624 |
+
return False
|
| 625 |
+
return True
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def _template_segments(
|
| 629 |
+
product_name: str,
|
| 630 |
+
tagline: str,
|
| 631 |
+
mood: str,
|
| 632 |
+
features: str,
|
| 633 |
+
shot_count: int,
|
| 634 |
+
seconds_per_segment: int,
|
| 635 |
+
concept_key: str,
|
| 636 |
+
) -> List[dict]:
|
| 637 |
+
c = _concept_meta(concept_key)
|
| 638 |
+
beats: list[tuple[str, str]] = list(c["beats"])
|
| 639 |
+
n = max(3, min(shot_count, len(beats)))
|
| 640 |
+
chosen = beats[:n]
|
| 641 |
+
out: List[dict] = []
|
| 642 |
+
feat_line = (features or "").strip()
|
| 643 |
+
product_line = f"{product_name}. {feat_line}" if feat_line else product_name
|
| 644 |
+
effective_mood = f"{mood}. {c['mood_tag']}"
|
| 645 |
+
|
| 646 |
+
for i, (camera_beat, dialogue) in enumerate(chosen, start=1):
|
| 647 |
+
dlg = f"VO: {tagline.strip()}" if (i == 1 and (tagline or "").strip()) else dialogue
|
| 648 |
+
out.append(
|
| 649 |
+
{
|
| 650 |
+
"segment_info": {
|
| 651 |
+
"segment_number": i,
|
| 652 |
+
"total_segments": n,
|
| 653 |
+
"duration": f"{seconds_per_segment}s",
|
| 654 |
+
"location": str(c["location"]),
|
| 655 |
+
"continuity_markers": _continuity_template(),
|
| 656 |
+
},
|
| 657 |
+
"character_description": {
|
| 658 |
+
"current_state": f"The hero subject is the product ({product_line}), not a person. "
|
| 659 |
+
"Keep identity, proportions, and branding accurate to the reference image.",
|
| 660 |
+
"voice_matching": str(c["voice_matching"]),
|
| 661 |
+
},
|
| 662 |
+
"scene_continuity": _scene_for_shot(effective_mood, camera_beat),
|
| 663 |
+
"action_timeline": {
|
| 664 |
+
"dialogue": dlg,
|
| 665 |
+
"synchronized_actions": _actions_for_duration(seconds_per_segment),
|
| 666 |
+
"micro_expressions": "n/a — product-focused",
|
| 667 |
+
"breathing_rhythm": str(c["rhythm"]),
|
| 668 |
+
"location_transition": str(c["transition"]),
|
| 669 |
+
"continuity_checkpoint": "match product identity, reflections, and edge lighting across segments when possible",
|
| 670 |
+
},
|
| 671 |
+
}
|
| 672 |
+
)
|
| 673 |
+
return out
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def _openai_messages(
|
| 677 |
+
product_name: str,
|
| 678 |
+
tagline: str,
|
| 679 |
+
mood: str,
|
| 680 |
+
features: str,
|
| 681 |
+
shot_count: int,
|
| 682 |
+
seconds_per_segment: int,
|
| 683 |
+
image_bytes: Optional[bytes],
|
| 684 |
+
image_media_type: Optional[str],
|
| 685 |
+
concept_key: str,
|
| 686 |
+
) -> list[dict[str, Any]]:
|
| 687 |
+
c = _concept_meta(concept_key)
|
| 688 |
+
ck = _normalize_concept(concept_key)
|
| 689 |
+
arc = _concept_arc(ck, shot_count)
|
| 690 |
+
action_buckets = _actions_for_duration(seconds_per_segment)
|
| 691 |
+
bucket_count = _bucket_count_for_seconds(seconds_per_segment)
|
| 692 |
+
system = (
|
| 693 |
+
"You are a senior commercial director and Veo prompt engineer. "
|
| 694 |
+
"Output ONLY valid JSON with key 'segments' (array). "
|
| 695 |
+
"Each item MUST match this TypeScript-like shape: "
|
| 696 |
+
"{ segment_info: { segment_number, total_segments, duration, location, continuity_markers: { start_position, end_position, start_expression, end_expression, start_gesture, end_gesture, location_status } }, "
|
| 697 |
+
"character_description: { current_state, voice_matching }, "
|
| 698 |
+
"scene_continuity: { environment, camera_position, camera_movement, lighting_state, background_elements, spatial_relationships }, "
|
| 699 |
+
"action_timeline: { dialogue, synchronized_actions (object with keys like '0:00-0:02'), micro_expressions, breathing_rhythm, location_transition, continuity_checkpoint } }. "
|
| 700 |
+
"Rules: the hero is always the physical product; no human face required; keep branding truthful; photoreal; "
|
| 701 |
+
f"exactly {shot_count} segments; each segment duration string must be '{seconds_per_segment}s'; "
|
| 702 |
+
"synchronized_actions must have the right number of time buckets for the segment length "
|
| 703 |
+
"(4 keys for 8s, 3 for 6s, 2 for 4s). "
|
| 704 |
+
"Never output vague placeholders like 'cinematic shot', 'nice lighting', 'beautiful scene', or 'generic product footage'. "
|
| 705 |
+
"Every segment must include concrete camera grammar (lens feeling, angle, motion intent), lighting behavior, and shot objective. "
|
| 706 |
+
"Keep shot progression intentional: hook -> proof -> differentiation -> payoff. "
|
| 707 |
+
f"Creative concept — {c['label']}: {c['director']} "
|
| 708 |
+
"Honor this concept in location, camera grammar, lighting, and VO/dialogue tone across every segment."
|
| 709 |
+
)
|
| 710 |
+
user_text = json.dumps(
|
| 711 |
+
{
|
| 712 |
+
"task": "generate_showcase_segments",
|
| 713 |
+
"instruction": (
|
| 714 |
+
"Describe camera, lighting, and motion to match the creative concept, "
|
| 715 |
+
"not a generic packshot unless the concept demands it."
|
| 716 |
+
),
|
| 717 |
+
"concept": {
|
| 718 |
+
"id": ck,
|
| 719 |
+
"label": c["label"],
|
| 720 |
+
"mood_tag": c["mood_tag"],
|
| 721 |
+
"director_notes": c["director"],
|
| 722 |
+
},
|
| 723 |
+
"brief": {
|
| 724 |
+
"product_name": product_name,
|
| 725 |
+
"tagline": tagline,
|
| 726 |
+
"user_mood_grade": mood,
|
| 727 |
+
"key_features": features or "n/a",
|
| 728 |
+
},
|
| 729 |
+
"constraints": {
|
| 730 |
+
"segment_count": shot_count,
|
| 731 |
+
"seconds_per_segment": seconds_per_segment,
|
| 732 |
+
"hero_subject": "physical product",
|
| 733 |
+
"branding_truthful": True,
|
| 734 |
+
"photoreal": True,
|
| 735 |
+
"required_bucket_count_per_segment": bucket_count,
|
| 736 |
+
"fallback_bucket_shape": action_buckets,
|
| 737 |
+
"must_avoid": [
|
| 738 |
+
"generic camera wording",
|
| 739 |
+
"repeating the same move in every segment",
|
| 740 |
+
"human-face dependent storytelling",
|
| 741 |
+
"over-claiming product capabilities",
|
| 742 |
+
],
|
| 743 |
+
},
|
| 744 |
+
"segment_blueprint": arc,
|
| 745 |
+
"response_schema": {
|
| 746 |
+
"segments": [
|
| 747 |
+
{
|
| 748 |
+
"segment_info": {
|
| 749 |
+
"segment_number": "number",
|
| 750 |
+
"total_segments": "number",
|
| 751 |
+
"duration": f"{seconds_per_segment}s",
|
| 752 |
+
"location": "string",
|
| 753 |
+
"continuity_markers": {
|
| 754 |
+
"start_position": "string",
|
| 755 |
+
"end_position": "string",
|
| 756 |
+
"start_expression": "string",
|
| 757 |
+
"end_expression": "string",
|
| 758 |
+
"start_gesture": "string",
|
| 759 |
+
"end_gesture": "string",
|
| 760 |
+
"location_status": "string",
|
| 761 |
+
},
|
| 762 |
+
},
|
| 763 |
+
"character_description": {
|
| 764 |
+
"current_state": "string",
|
| 765 |
+
"voice_matching": "string",
|
| 766 |
+
},
|
| 767 |
+
"scene_continuity": {
|
| 768 |
+
"environment": "string",
|
| 769 |
+
"camera_position": "string",
|
| 770 |
+
"camera_movement": "string",
|
| 771 |
+
"lighting_state": "string",
|
| 772 |
+
"background_elements": "string",
|
| 773 |
+
"spatial_relationships": "string",
|
| 774 |
+
},
|
| 775 |
+
"action_timeline": {
|
| 776 |
+
"dialogue": "string",
|
| 777 |
+
"synchronized_actions": "record<string,string>",
|
| 778 |
+
"micro_expressions": "string",
|
| 779 |
+
"breathing_rhythm": "string",
|
| 780 |
+
"location_transition": "string",
|
| 781 |
+
"continuity_checkpoint": "string",
|
| 782 |
+
},
|
| 783 |
+
}
|
| 784 |
+
]
|
| 785 |
+
},
|
| 786 |
+
}
|
| 787 |
+
)
|
| 788 |
+
user: dict[str, Any] = {"role": "user", "content": []}
|
| 789 |
+
if image_bytes and image_media_type:
|
| 790 |
+
b64 = base64.b64encode(image_bytes).decode("utf-8")
|
| 791 |
+
user["content"].append(
|
| 792 |
+
{
|
| 793 |
+
"type": "image_url",
|
| 794 |
+
"image_url": {"url": f"data:{image_media_type};base64,{b64}"},
|
| 795 |
+
}
|
| 796 |
+
)
|
| 797 |
+
user["content"].append({"type": "text", "text": user_text})
|
| 798 |
+
return [{"role": "system", "content": system}, user]
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
def _normalize_segments(raw: Any, expected: int, seconds: int) -> List[dict]:
|
| 802 |
+
if not isinstance(raw, dict) or "segments" not in raw:
|
| 803 |
+
raise ValueError("Invalid JSON: missing segments")
|
| 804 |
+
segs = raw["segments"]
|
| 805 |
+
if not isinstance(segs, list) or not segs:
|
| 806 |
+
raise ValueError("Invalid segments array")
|
| 807 |
+
out: List[dict] = []
|
| 808 |
+
required_actions = _actions_for_duration(seconds)
|
| 809 |
+
required_bucket_count = _bucket_count_for_seconds(seconds)
|
| 810 |
+
for i, s in enumerate(segs[:expected], start=1):
|
| 811 |
+
if not isinstance(s, dict):
|
| 812 |
+
continue
|
| 813 |
+
si = s.get("segment_info") if isinstance(s.get("segment_info"), dict) else {}
|
| 814 |
+
si["segment_number"] = i
|
| 815 |
+
si["total_segments"] = min(len(segs), expected)
|
| 816 |
+
si["duration"] = f"{seconds}s"
|
| 817 |
+
si["continuity_markers"] = si.get("continuity_markers") or _continuity_template()
|
| 818 |
+
s["segment_info"] = si
|
| 819 |
+
if not isinstance(s.get("character_description"), dict):
|
| 820 |
+
s["character_description"] = {}
|
| 821 |
+
if not isinstance(s.get("scene_continuity"), dict):
|
| 822 |
+
s["scene_continuity"] = {}
|
| 823 |
+
if not isinstance(s.get("action_timeline"), dict):
|
| 824 |
+
s["action_timeline"] = {}
|
| 825 |
+
|
| 826 |
+
scene = s["scene_continuity"]
|
| 827 |
+
timeline = s["action_timeline"]
|
| 828 |
+
character = s["character_description"]
|
| 829 |
+
scene.setdefault("environment", "minimal cinematic set with product-first framing")
|
| 830 |
+
scene.setdefault("camera_position", "commercial camera setup with stable product readability")
|
| 831 |
+
scene.setdefault("camera_movement", "slow intentional move to improve product perception")
|
| 832 |
+
scene.setdefault("lighting_state", "controlled key and rim light preserving material accuracy")
|
| 833 |
+
scene.setdefault("background_elements", "clean background with no distracting clutter")
|
| 834 |
+
scene.setdefault("spatial_relationships", "product remains primary subject with clear silhouette")
|
| 835 |
+
|
| 836 |
+
character.setdefault(
|
| 837 |
+
"current_state",
|
| 838 |
+
"The hero subject is the physical product only; preserve branding and proportions.",
|
| 839 |
+
)
|
| 840 |
+
character.setdefault("voice_matching", "Voice optional; keep tone aligned to concept.")
|
| 841 |
+
|
| 842 |
+
timeline.setdefault("dialogue", "Minimal VO: emphasize one clear product value.")
|
| 843 |
+
timeline.setdefault("micro_expressions", "n/a - product-focused")
|
| 844 |
+
timeline.setdefault("breathing_rhythm", "measured commercial rhythm")
|
| 845 |
+
timeline.setdefault("location_transition", "maintain visual continuity across cuts")
|
| 846 |
+
timeline.setdefault("continuity_checkpoint", "preserve product identity and lighting continuity")
|
| 847 |
+
|
| 848 |
+
actions = timeline.get("synchronized_actions")
|
| 849 |
+
if not isinstance(actions, dict) or len(actions) != required_bucket_count:
|
| 850 |
+
timeline["synchronized_actions"] = required_actions
|
| 851 |
+
if not _segment_has_substance(s):
|
| 852 |
+
timeline["dialogue"] = "VO: Clear, specific value proposition tied to this shot."
|
| 853 |
+
scene["camera_movement"] = "purposeful move with clear beginning, reveal, and resolve"
|
| 854 |
+
timeline["synchronized_actions"] = required_actions
|
| 855 |
+
out.append(s)
|
| 856 |
+
if len(out) < 3:
|
| 857 |
+
raise ValueError("Too few valid segments")
|
| 858 |
+
return out[:expected]
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
class DirectConceptsBody(BaseModel):
|
| 862 |
+
product_name: str = Field(..., min_length=1, max_length=200)
|
| 863 |
+
tagline: str = Field(default="", max_length=400)
|
| 864 |
+
mood: str = Field(default="", max_length=400)
|
| 865 |
+
features: str = Field(default="", max_length=4000)
|
| 866 |
+
count: int = Field(default=10, ge=3, le=12)
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
class DirectOneConceptBody(BaseModel):
|
| 870 |
+
product_name: str = Field(..., min_length=1, max_length=200)
|
| 871 |
+
tagline: str = Field(default="", max_length=400)
|
| 872 |
+
mood: str = Field(default="", max_length=400)
|
| 873 |
+
features: str = Field(default="", max_length=4000)
|
| 874 |
+
exclude: list[str] = Field(default_factory=list)
|
| 875 |
+
index: int = Field(default=1, ge=1, le=20)
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def _template_direct_concepts(
|
| 879 |
+
product_name: str,
|
| 880 |
+
tagline: str,
|
| 881 |
+
mood: str,
|
| 882 |
+
features: str,
|
| 883 |
+
count: int,
|
| 884 |
+
) -> list[str]:
|
| 885 |
+
base = [
|
| 886 |
+
"Luxury studio hero with soft bloom and precision macro details",
|
| 887 |
+
"UGC creator desk walkthrough with candid handheld authenticity",
|
| 888 |
+
"Tech minimal void set with hard-edge lighting and geometric moves",
|
| 889 |
+
"Lifestyle morning ritual in natural daylight with tactile closeups",
|
| 890 |
+
"Bold color editorial with gel lights and graphic shadows",
|
| 891 |
+
"Top-down unboxing ASMR with satisfying reveal choreography",
|
| 892 |
+
"High-energy performance montage with dynamic camera momentum",
|
| 893 |
+
"Moody cinematic noir with deep contrast and silhouette reveals",
|
| 894 |
+
"Playful stop-motion inspired tabletop with rhythmic prop beats",
|
| 895 |
+
"Outdoor adventure story with golden hour product utility moments",
|
| 896 |
+
"Artisan craftsmanship documentary style with texture-first macro rhythm",
|
| 897 |
+
"Futuristic holographic showcase with clean sci-fi interface energy",
|
| 898 |
+
]
|
| 899 |
+
hooks: list[str] = []
|
| 900 |
+
if tagline.strip():
|
| 901 |
+
hooks.append(f'Tagline pulse: "{tagline.strip()}"')
|
| 902 |
+
if mood.strip():
|
| 903 |
+
hooks.append(f"Mood anchor: {mood.strip()}")
|
| 904 |
+
if features.strip():
|
| 905 |
+
feature_line = " ".join(features.strip().split())
|
| 906 |
+
hooks.append(f"Feature focus: {feature_line[:180]}")
|
| 907 |
+
suffix = f" for {product_name.strip()}"
|
| 908 |
+
out: list[str] = []
|
| 909 |
+
for i in range(count):
|
| 910 |
+
concept = f"{base[i % len(base)]}{suffix}"
|
| 911 |
+
if hooks:
|
| 912 |
+
concept = f"{concept}. {hooks[i % len(hooks)]}"
|
| 913 |
+
out.append(concept)
|
| 914 |
+
return out
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def _llm_direct_concepts(
|
| 918 |
+
*,
|
| 919 |
+
product_name: str,
|
| 920 |
+
tagline: str,
|
| 921 |
+
mood: str,
|
| 922 |
+
features: str,
|
| 923 |
+
count: int,
|
| 924 |
+
) -> list[str]:
|
| 925 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 926 |
+
if not api_key:
|
| 927 |
+
return _template_direct_concepts(product_name, tagline, mood, features, count)
|
| 928 |
+
try:
|
| 929 |
+
from openai import OpenAI
|
| 930 |
+
|
| 931 |
+
client = OpenAI(api_key=api_key)
|
| 932 |
+
system = (
|
| 933 |
+
"You are a senior creative director for product video ads. "
|
| 934 |
+
"Generate distinct, production-ready creative concepts for image-to-video generation. "
|
| 935 |
+
"Return strict JSON: {\"concepts\": [\"...\"]}. "
|
| 936 |
+
"Each concept must be one sentence, concrete camera/lighting/story style, no hashtags, no numbering."
|
| 937 |
+
)
|
| 938 |
+
user = json.dumps(
|
| 939 |
+
{
|
| 940 |
+
"task": "generate_direct_video_concepts",
|
| 941 |
+
"count": count,
|
| 942 |
+
"constraints": {
|
| 943 |
+
"distinct_styles": True,
|
| 944 |
+
"single_sentence_each": True,
|
| 945 |
+
"max_chars_per_concept": 220,
|
| 946 |
+
"focused_on_product_identity": True,
|
| 947 |
+
"suitable_for_15s_video": True,
|
| 948 |
+
},
|
| 949 |
+
"brief": {
|
| 950 |
+
"product_name": product_name,
|
| 951 |
+
"tagline": tagline or "n/a",
|
| 952 |
+
"mood": mood or "n/a",
|
| 953 |
+
"features": features or "n/a",
|
| 954 |
+
},
|
| 955 |
+
}
|
| 956 |
+
)
|
| 957 |
+
resp = client.chat.completions.create(
|
| 958 |
+
model=DEFAULT_MODEL,
|
| 959 |
+
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
| 960 |
+
response_format={"type": "json_object"},
|
| 961 |
+
temperature=0.9,
|
| 962 |
+
)
|
| 963 |
+
txt = resp.choices[0].message.content or "{}"
|
| 964 |
+
data = json.loads(txt)
|
| 965 |
+
raw = data.get("concepts")
|
| 966 |
+
if not isinstance(raw, list):
|
| 967 |
+
return _template_direct_concepts(product_name, tagline, mood, features, count)
|
| 968 |
+
cleaned: list[str] = []
|
| 969 |
+
seen = set()
|
| 970 |
+
for x in raw:
|
| 971 |
+
s = " ".join(str(x or "").split()).strip()
|
| 972 |
+
if not s:
|
| 973 |
+
continue
|
| 974 |
+
if len(s) > 220:
|
| 975 |
+
s = s[:220].rstrip()
|
| 976 |
+
key = s.lower()
|
| 977 |
+
if key in seen:
|
| 978 |
+
continue
|
| 979 |
+
seen.add(key)
|
| 980 |
+
cleaned.append(s)
|
| 981 |
+
if len(cleaned) >= count:
|
| 982 |
+
break
|
| 983 |
+
if len(cleaned) < count:
|
| 984 |
+
cleaned.extend(
|
| 985 |
+
[
|
| 986 |
+
c
|
| 987 |
+
for c in _template_direct_concepts(product_name, tagline, mood, features, count)
|
| 988 |
+
if c.lower() not in seen
|
| 989 |
+
][: count - len(cleaned)]
|
| 990 |
+
)
|
| 991 |
+
return cleaned[:count]
|
| 992 |
+
except Exception:
|
| 993 |
+
return _template_direct_concepts(product_name, tagline, mood, features, count)
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
@router.post("/showcase/direct-concepts")
|
| 997 |
+
async def showcase_direct_concepts(body: DirectConceptsBody):
|
| 998 |
+
concepts = await asyncio.to_thread(
|
| 999 |
+
_llm_direct_concepts,
|
| 1000 |
+
product_name=body.product_name.strip(),
|
| 1001 |
+
tagline=body.tagline.strip(),
|
| 1002 |
+
mood=body.mood.strip(),
|
| 1003 |
+
features=body.features.strip(),
|
| 1004 |
+
count=int(body.count),
|
| 1005 |
+
)
|
| 1006 |
+
return {"concepts": concepts}
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
@router.post("/showcase/direct-concept-one")
|
| 1010 |
+
async def showcase_direct_concept_one(body: DirectOneConceptBody):
|
| 1011 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 1012 |
+
excludes = [" ".join(str(x or "").split()).strip().lower() for x in body.exclude if str(x or "").strip()]
|
| 1013 |
+
excludes_set = set(excludes)
|
| 1014 |
+
|
| 1015 |
+
if api_key:
|
| 1016 |
+
try:
|
| 1017 |
+
from openai import OpenAI
|
| 1018 |
+
|
| 1019 |
+
client = OpenAI(api_key=api_key)
|
| 1020 |
+
system = (
|
| 1021 |
+
"You are a senior creative director for product video ads. "
|
| 1022 |
+
"Return strict JSON only: {\"concept\": \"...\"}. "
|
| 1023 |
+
"One sentence, concrete visual direction, no numbering, no hashtags."
|
| 1024 |
+
)
|
| 1025 |
+
user = json.dumps(
|
| 1026 |
+
{
|
| 1027 |
+
"task": "regenerate_single_direct_video_concept",
|
| 1028 |
+
"brief": {
|
| 1029 |
+
"product_name": body.product_name.strip(),
|
| 1030 |
+
"tagline": body.tagline.strip() or "n/a",
|
| 1031 |
+
"mood": body.mood.strip() or "n/a",
|
| 1032 |
+
"features": body.features.strip() or "n/a",
|
| 1033 |
+
},
|
| 1034 |
+
"slot_index": int(body.index),
|
| 1035 |
+
"must_be_distinct_from": list(excludes_set),
|
| 1036 |
+
}
|
| 1037 |
+
)
|
| 1038 |
+
resp = client.chat.completions.create(
|
| 1039 |
+
model=DEFAULT_MODEL,
|
| 1040 |
+
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
| 1041 |
+
response_format={"type": "json_object"},
|
| 1042 |
+
temperature=1.0,
|
| 1043 |
+
)
|
| 1044 |
+
txt = resp.choices[0].message.content or "{}"
|
| 1045 |
+
data = json.loads(txt)
|
| 1046 |
+
c = " ".join(str(data.get("concept") or "").split()).strip()
|
| 1047 |
+
if c and c.lower() not in excludes_set:
|
| 1048 |
+
return {"concept": c[:220]}
|
| 1049 |
+
except Exception:
|
| 1050 |
+
pass
|
| 1051 |
+
|
| 1052 |
+
# Template fallback: pick first concept not in excludes.
|
| 1053 |
+
templ = _template_direct_concepts(
|
| 1054 |
+
body.product_name.strip(),
|
| 1055 |
+
body.tagline.strip(),
|
| 1056 |
+
body.mood.strip(),
|
| 1057 |
+
body.features.strip(),
|
| 1058 |
+
12,
|
| 1059 |
+
)
|
| 1060 |
+
for c in templ:
|
| 1061 |
+
if c.lower() not in excludes_set:
|
| 1062 |
+
return {"concept": c}
|
| 1063 |
+
return {"concept": templ[0] if templ else "Cinematic premium product showcase with clear differentiation."}
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
@router.post("/showcase/plan-stream")
|
| 1067 |
+
async def showcase_plan_stream(
|
| 1068 |
+
productName: str = Form(...),
|
| 1069 |
+
tagline: str = Form(""),
|
| 1070 |
+
mood: str = Form("premium, high contrast, soft bloom"),
|
| 1071 |
+
features: str = Form(""),
|
| 1072 |
+
shotCount: int = Form(5),
|
| 1073 |
+
secondsPerSegment: Optional[int] = Form(None),
|
| 1074 |
+
creativeConcept: str = Form("luxury_studio"),
|
| 1075 |
+
image: Optional[UploadFile] = File(None),
|
| 1076 |
+
heroImageUrl: str = Form(""),
|
| 1077 |
+
):
|
| 1078 |
+
shot_count = max(3, min(int(shotCount), 6))
|
| 1079 |
+
requested_seconds = int(secondsPerSegment) if secondsPerSegment is not None else None
|
| 1080 |
+
if requested_seconds in (4, 6, 8):
|
| 1081 |
+
seconds = requested_seconds
|
| 1082 |
+
else:
|
| 1083 |
+
seconds = _heuristic_seconds_per_segment(
|
| 1084 |
+
concept_key=creativeConcept,
|
| 1085 |
+
product_name=productName,
|
| 1086 |
+
tagline=tagline,
|
| 1087 |
+
mood=mood,
|
| 1088 |
+
features=features,
|
| 1089 |
+
shot_count=shot_count,
|
| 1090 |
+
)
|
| 1091 |
+
concept = _normalize_concept(creativeConcept)
|
| 1092 |
+
concept_meta = _concept_meta(concept)
|
| 1093 |
+
|
| 1094 |
+
image_bytes: Optional[bytes] = None
|
| 1095 |
+
image_media_type: Optional[str] = None
|
| 1096 |
+
if image is not None:
|
| 1097 |
+
raw = await image.read()
|
| 1098 |
+
if raw:
|
| 1099 |
+
image_bytes = raw
|
| 1100 |
+
image_media_type = image.content_type or "image/jpeg"
|
| 1101 |
+
if image_bytes is None and (heroImageUrl or "").strip():
|
| 1102 |
+
try:
|
| 1103 |
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 1104 |
+
r = await client.get(
|
| 1105 |
+
heroImageUrl.strip(),
|
| 1106 |
+
headers={"User-Agent": "ProductShowcase/1.0"},
|
| 1107 |
+
)
|
| 1108 |
+
r.raise_for_status()
|
| 1109 |
+
image_bytes = r.content
|
| 1110 |
+
ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
|
| 1111 |
+
image_media_type = ct if ct.startswith("image/") else "image/jpeg"
|
| 1112 |
+
except Exception as e:
|
| 1113 |
+
raise HTTPException(
|
| 1114 |
+
status_code=400,
|
| 1115 |
+
detail=f"Could not load heroImageUrl for planning: {e}",
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
async def gen():
|
| 1119 |
+
prompt_id = str(uuid.uuid4())
|
| 1120 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 1121 |
+
segments: List[dict] = []
|
| 1122 |
+
selected_seconds = seconds
|
| 1123 |
+
|
| 1124 |
+
try:
|
| 1125 |
+
if api_key:
|
| 1126 |
+
from openai import OpenAI
|
| 1127 |
+
|
| 1128 |
+
client = OpenAI(api_key=api_key)
|
| 1129 |
+
if requested_seconds not in (4, 6, 8):
|
| 1130 |
+
selected_seconds = await asyncio.to_thread(
|
| 1131 |
+
_llm_seconds_per_segment,
|
| 1132 |
+
client=client,
|
| 1133 |
+
model=DEFAULT_MODEL,
|
| 1134 |
+
concept_key=concept,
|
| 1135 |
+
product_name=productName,
|
| 1136 |
+
tagline=tagline,
|
| 1137 |
+
mood=mood,
|
| 1138 |
+
features=features,
|
| 1139 |
+
shot_count=shot_count,
|
| 1140 |
+
fallback_seconds=seconds,
|
| 1141 |
+
)
|
| 1142 |
+
messages = _openai_messages(
|
| 1143 |
+
productName,
|
| 1144 |
+
tagline,
|
| 1145 |
+
mood,
|
| 1146 |
+
features,
|
| 1147 |
+
shot_count,
|
| 1148 |
+
selected_seconds,
|
| 1149 |
+
image_bytes,
|
| 1150 |
+
image_media_type,
|
| 1151 |
+
concept,
|
| 1152 |
+
)
|
| 1153 |
+
|
| 1154 |
+
def call_llm():
|
| 1155 |
+
return client.chat.completions.create(
|
| 1156 |
+
model=DEFAULT_MODEL,
|
| 1157 |
+
messages=messages,
|
| 1158 |
+
response_format={"type": "json_object"},
|
| 1159 |
+
temperature=0.7,
|
| 1160 |
+
)
|
| 1161 |
+
|
| 1162 |
+
completion = await asyncio.to_thread(call_llm)
|
| 1163 |
+
text = completion.choices[0].message.content or "{}"
|
| 1164 |
+
data = json.loads(text)
|
| 1165 |
+
segments = _normalize_segments(data, shot_count, selected_seconds)
|
| 1166 |
+
else:
|
| 1167 |
+
segments = _template_segments(
|
| 1168 |
+
productName,
|
| 1169 |
+
tagline,
|
| 1170 |
+
mood,
|
| 1171 |
+
features,
|
| 1172 |
+
shot_count,
|
| 1173 |
+
selected_seconds,
|
| 1174 |
+
concept,
|
| 1175 |
+
)
|
| 1176 |
+
except Exception as e:
|
| 1177 |
+
yield json.dumps(
|
| 1178 |
+
{
|
| 1179 |
+
"event": "error",
|
| 1180 |
+
"message": str(e),
|
| 1181 |
+
"error_type": type(e).__name__,
|
| 1182 |
+
}
|
| 1183 |
+
) + "\n"
|
| 1184 |
+
return
|
| 1185 |
+
|
| 1186 |
+
yield json.dumps(
|
| 1187 |
+
{
|
| 1188 |
+
"event": "start",
|
| 1189 |
+
"total_segments": len(segments),
|
| 1190 |
+
"model": DEFAULT_MODEL if api_key else "template",
|
| 1191 |
+
}
|
| 1192 |
+
) + "\n"
|
| 1193 |
+
|
| 1194 |
+
total = len(segments)
|
| 1195 |
+
for idx, seg in enumerate(segments):
|
| 1196 |
+
progress = int((idx + 1) / max(total, 1) * 100)
|
| 1197 |
+
yield json.dumps(
|
| 1198 |
+
{
|
| 1199 |
+
"event": "segment",
|
| 1200 |
+
"index": idx,
|
| 1201 |
+
"total": total,
|
| 1202 |
+
"progress": progress,
|
| 1203 |
+
"segment": seg,
|
| 1204 |
+
}
|
| 1205 |
+
) + "\n"
|
| 1206 |
+
await asyncio.sleep(0.05)
|
| 1207 |
+
|
| 1208 |
+
payload = {
|
| 1209 |
+
"segments": segments,
|
| 1210 |
+
"environment": f"{concept_meta['label']} · {mood}",
|
| 1211 |
+
"creative_concept": concept,
|
| 1212 |
+
"seconds_per_segment": selected_seconds,
|
| 1213 |
+
}
|
| 1214 |
+
yield json.dumps(
|
| 1215 |
+
{
|
| 1216 |
+
"event": "complete",
|
| 1217 |
+
"message": "Shot plan ready",
|
| 1218 |
+
"prompt_id": prompt_id,
|
| 1219 |
+
"payload": payload,
|
| 1220 |
+
}
|
| 1221 |
+
) + "\n"
|
| 1222 |
+
|
| 1223 |
+
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
backend/api/video_export.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Export API
|
| 3 |
+
Handles merging multiple video clips into a single output video
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
| 7 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 8 |
+
from typing import List, Optional, Tuple
|
| 9 |
+
import os
|
| 10 |
+
import tempfile
|
| 11 |
+
import subprocess
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_video_dimensions(video_path: Path) -> Tuple[int, int]:
|
| 19 |
+
"""Get video width and height using ffprobe"""
|
| 20 |
+
try:
|
| 21 |
+
cmd = [
|
| 22 |
+
'ffprobe',
|
| 23 |
+
'-v', 'error',
|
| 24 |
+
'-select_streams', 'v:0',
|
| 25 |
+
'-show_entries', 'stream=width,height',
|
| 26 |
+
'-of', 'json',
|
| 27 |
+
str(video_path)
|
| 28 |
+
]
|
| 29 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
| 30 |
+
if result.returncode == 0:
|
| 31 |
+
data = json.loads(result.stdout)
|
| 32 |
+
streams = data.get('streams', [])
|
| 33 |
+
if streams:
|
| 34 |
+
width = streams[0].get('width', 1080)
|
| 35 |
+
height = streams[0].get('height', 1920)
|
| 36 |
+
return (width, height)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"⚠️ Could not detect video dimensions: {e}")
|
| 39 |
+
|
| 40 |
+
# Default to 9:16 portrait if detection fails
|
| 41 |
+
return (1080, 1920)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.post("/export/merge")
|
| 45 |
+
async def merge_videos(
|
| 46 |
+
clips_data: str = Form(...), # JSON string with clip metadata
|
| 47 |
+
files: List[UploadFile] = File(...)
|
| 48 |
+
):
|
| 49 |
+
"""
|
| 50 |
+
Merge multiple video clips into a single output video
|
| 51 |
+
|
| 52 |
+
clips_data: JSON string containing array of clip objects with:
|
| 53 |
+
- index: order in timeline
|
| 54 |
+
- startTime: start time in clip (seconds)
|
| 55 |
+
- endTime: end time in clip (seconds)
|
| 56 |
+
- type: 'video' or 'image'
|
| 57 |
+
- duration: duration for images (seconds)
|
| 58 |
+
|
| 59 |
+
files: Video/image files in the same order as clips_data
|
| 60 |
+
"""
|
| 61 |
+
try:
|
| 62 |
+
# Parse clips data
|
| 63 |
+
clips = json.loads(clips_data)
|
| 64 |
+
|
| 65 |
+
if len(clips) != len(files):
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=400,
|
| 68 |
+
detail=f"Mismatch: {len(clips)} clips but {len(files)} files"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if len(clips) == 0:
|
| 72 |
+
raise HTTPException(status_code=400, detail="No clips to merge")
|
| 73 |
+
|
| 74 |
+
# Create temporary directory for processing
|
| 75 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 76 |
+
temp_path = Path(temp_dir)
|
| 77 |
+
|
| 78 |
+
# Save all uploaded files
|
| 79 |
+
file_paths = []
|
| 80 |
+
for i, file in enumerate(files):
|
| 81 |
+
clip = clips[i]
|
| 82 |
+
file_path = temp_path / f"input_{i}.{file.filename.split('.')[-1] if '.' in file.filename else 'mp4'}"
|
| 83 |
+
|
| 84 |
+
with open(file_path, 'wb') as f:
|
| 85 |
+
content = await file.read()
|
| 86 |
+
f.write(content)
|
| 87 |
+
|
| 88 |
+
file_paths.append(file_path)
|
| 89 |
+
|
| 90 |
+
# Detect dimensions from first video to preserve aspect ratio
|
| 91 |
+
target_width, target_height = get_video_dimensions(file_paths[0])
|
| 92 |
+
print(f"📐 Detected video dimensions: {target_width}x{target_height}")
|
| 93 |
+
|
| 94 |
+
# Build FFmpeg command
|
| 95 |
+
output_path = temp_path / "output.mp4"
|
| 96 |
+
|
| 97 |
+
# Helper function to check if video has audio stream
|
| 98 |
+
def has_audio_stream(video_path: Path) -> bool:
|
| 99 |
+
"""Check if video file has an audio stream"""
|
| 100 |
+
try:
|
| 101 |
+
cmd = [
|
| 102 |
+
'ffprobe',
|
| 103 |
+
'-v', 'error',
|
| 104 |
+
'-select_streams', 'a',
|
| 105 |
+
'-show_entries', 'stream=codec_type',
|
| 106 |
+
'-of', 'json',
|
| 107 |
+
str(video_path)
|
| 108 |
+
]
|
| 109 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
| 110 |
+
if result.returncode == 0:
|
| 111 |
+
import json as json_lib
|
| 112 |
+
data = json_lib.loads(result.stdout)
|
| 113 |
+
streams = data.get('streams', [])
|
| 114 |
+
return len(streams) > 0
|
| 115 |
+
return False
|
| 116 |
+
except Exception:
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
# Build filter complex - process clips in order
|
| 120 |
+
filter_parts = []
|
| 121 |
+
input_args = []
|
| 122 |
+
concat_inputs = []
|
| 123 |
+
|
| 124 |
+
# Process all clips in order
|
| 125 |
+
input_index = 0
|
| 126 |
+
for clip_idx, clip in enumerate(clips):
|
| 127 |
+
file_path = file_paths[clip_idx]
|
| 128 |
+
|
| 129 |
+
if clip['type'] == 'video':
|
| 130 |
+
clip_duration = clip['endTime'] - clip['startTime']
|
| 131 |
+
input_args.extend(['-i', str(file_path)])
|
| 132 |
+
|
| 133 |
+
# Check if video has audio
|
| 134 |
+
has_audio = has_audio_stream(file_path)
|
| 135 |
+
|
| 136 |
+
# Trim video and scale to match first video's dimensions
|
| 137 |
+
# Using scale with force_original_aspect_ratio to handle any size differences
|
| 138 |
+
filter_parts.append(
|
| 139 |
+
f"[{input_index}:v]trim=start={clip['startTime']}:end={clip['endTime']},"
|
| 140 |
+
f"setpts=PTS-STARTPTS,"
|
| 141 |
+
f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
|
| 142 |
+
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
|
| 143 |
+
f"setsar=1[v{clip_idx}];"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
if has_audio:
|
| 147 |
+
# Use existing audio stream
|
| 148 |
+
filter_parts.append(
|
| 149 |
+
f"[{input_index}:a]atrim=start={clip['startTime']}:end={clip['endTime']},"
|
| 150 |
+
f"asetpts=PTS-STARTPTS[a{clip_idx}];"
|
| 151 |
+
)
|
| 152 |
+
else:
|
| 153 |
+
# Generate silent audio for videos without audio
|
| 154 |
+
filter_parts.append(
|
| 155 |
+
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{clip_duration},"
|
| 156 |
+
f"asetpts=PTS-STARTPTS[a{clip_idx}];"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
input_index += 1
|
| 160 |
+
else:
|
| 161 |
+
# Image clip
|
| 162 |
+
clip_duration = clip.get('duration', 3.0) # Default 3 seconds for images
|
| 163 |
+
input_args.extend(['-loop', '1', '-t', str(clip_duration), '-i', str(file_path)])
|
| 164 |
+
|
| 165 |
+
# Scale image to match video dimensions
|
| 166 |
+
filter_parts.append(
|
| 167 |
+
f"[{input_index}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
|
| 168 |
+
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
|
| 169 |
+
f"setsar=1,format=yuv420p[v{clip_idx}];"
|
| 170 |
+
)
|
| 171 |
+
# Generate silent audio
|
| 172 |
+
filter_parts.append(
|
| 173 |
+
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{clip_duration},"
|
| 174 |
+
f"asetpts=PTS-STARTPTS[a{clip_idx}];"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
input_index += 1
|
| 178 |
+
|
| 179 |
+
# Add to concat inputs in order
|
| 180 |
+
concat_inputs.append(f"[v{clip_idx}][a{clip_idx}]")
|
| 181 |
+
|
| 182 |
+
# Build complete filter complex
|
| 183 |
+
filter_complex = ''.join(filter_parts)
|
| 184 |
+
filter_complex += f"{''.join(concat_inputs)}concat=n={len(clips)}:v=1:a=1[outv][outa]"
|
| 185 |
+
|
| 186 |
+
# Build FFmpeg command
|
| 187 |
+
ffmpeg_cmd = [
|
| 188 |
+
'ffmpeg',
|
| 189 |
+
*input_args,
|
| 190 |
+
'-filter_complex', filter_complex,
|
| 191 |
+
'-map', '[outv]',
|
| 192 |
+
'-map', '[outa]',
|
| 193 |
+
'-c:v', 'libx264',
|
| 194 |
+
'-c:a', 'aac',
|
| 195 |
+
'-movflags', '+faststart',
|
| 196 |
+
'-y', # Overwrite output
|
| 197 |
+
str(output_path)
|
| 198 |
+
]
|
| 199 |
+
|
| 200 |
+
print(f"🎬 Running FFmpeg merge with dimensions: {target_width}x{target_height}")
|
| 201 |
+
|
| 202 |
+
# Run FFmpeg
|
| 203 |
+
result = subprocess.run(
|
| 204 |
+
ffmpeg_cmd,
|
| 205 |
+
capture_output=True,
|
| 206 |
+
text=True,
|
| 207 |
+
timeout=300 # 5 minute timeout
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
if result.returncode != 0:
|
| 211 |
+
print(f"❌ FFmpeg error: {result.stderr}")
|
| 212 |
+
raise HTTPException(
|
| 213 |
+
status_code=500,
|
| 214 |
+
detail=f"FFmpeg failed: {result.stderr[:500]}"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
if not output_path.exists():
|
| 218 |
+
raise HTTPException(status_code=500, detail="Output file was not created")
|
| 219 |
+
|
| 220 |
+
# Read the entire file into memory before temp directory is deleted
|
| 221 |
+
print(f"📦 Reading merged video file ({output_path.stat().st_size / 1024 / 1024:.2f} MB)...")
|
| 222 |
+
with open(output_path, 'rb') as f:
|
| 223 |
+
video_content = f.read()
|
| 224 |
+
|
| 225 |
+
print(f"✅ Video merged successfully: {target_width}x{target_height}")
|
| 226 |
+
|
| 227 |
+
# Return the merged video file
|
| 228 |
+
def generate():
|
| 229 |
+
# Yield in chunks to avoid loading entire file in memory at once
|
| 230 |
+
chunk_size = 8192
|
| 231 |
+
for i in range(0, len(video_content), chunk_size):
|
| 232 |
+
yield video_content[i:i + chunk_size]
|
| 233 |
+
|
| 234 |
+
return StreamingResponse(
|
| 235 |
+
generate(),
|
| 236 |
+
media_type="video/mp4",
|
| 237 |
+
headers={
|
| 238 |
+
"Content-Disposition": "attachment; filename=exported-video.mp4",
|
| 239 |
+
"Content-Type": "video/mp4",
|
| 240 |
+
"Content-Length": str(len(video_content))
|
| 241 |
+
}
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
except json.JSONDecodeError as e:
|
| 245 |
+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
|
| 246 |
+
except subprocess.TimeoutExpired:
|
| 247 |
+
raise HTTPException(status_code=504, detail="Video processing timed out")
|
| 248 |
+
except Exception as e:
|
| 249 |
+
print(f"❌ Export error: {str(e)}")
|
| 250 |
+
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
|
backend/api/video_generation.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Generation API endpoints
|
| 3 |
+
Handles KIE API integration with SSE support for real-time updates
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Request, Query
|
| 7 |
+
from fastapi.responses import StreamingResponse, JSONResponse, Response
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
from typing import List, Optional, Dict, Any
|
| 10 |
+
import httpx
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
import tempfile
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
|
| 20 |
+
from utils.image_processor import compress_and_store_image
|
| 21 |
+
from utils.public_url import get_public_base_url
|
| 22 |
+
from utils.storage import video_results, sse_clients, cleanup_old_results
|
| 23 |
+
|
| 24 |
+
router = APIRouter()
|
| 25 |
+
|
| 26 |
+
KIE_API_BASE = "https://api.kie.ai"
|
| 27 |
+
|
| 28 |
+
_ALLOWED_VEO_CLIP_SECONDS = frozenset({4, 6, 8})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _normalize_veo_duration_seconds(v: Optional[int]) -> Optional[int]:
|
| 32 |
+
if v is None:
|
| 33 |
+
return None
|
| 34 |
+
try:
|
| 35 |
+
n = int(v)
|
| 36 |
+
except (TypeError, ValueError):
|
| 37 |
+
return None
|
| 38 |
+
return n if n in _ALLOWED_VEO_CLIP_SECONDS else None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _ffmpeg_trim_to_seconds(src: Path, dest: Path, seconds: int) -> None:
|
| 42 |
+
cmd = [
|
| 43 |
+
"ffmpeg",
|
| 44 |
+
"-y",
|
| 45 |
+
"-i",
|
| 46 |
+
str(src),
|
| 47 |
+
"-t",
|
| 48 |
+
str(seconds),
|
| 49 |
+
"-c:v",
|
| 50 |
+
"libx264",
|
| 51 |
+
"-preset",
|
| 52 |
+
"veryfast",
|
| 53 |
+
"-crf",
|
| 54 |
+
"20",
|
| 55 |
+
"-c:a",
|
| 56 |
+
"aac",
|
| 57 |
+
"-b:a",
|
| 58 |
+
"128k",
|
| 59 |
+
"-movflags",
|
| 60 |
+
"+faststart",
|
| 61 |
+
str(dest),
|
| 62 |
+
]
|
| 63 |
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
| 64 |
+
if r.returncode != 0:
|
| 65 |
+
tail = (r.stderr or "")[-900:]
|
| 66 |
+
raise RuntimeError(tail or "ffmpeg trim failed")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# Request/Response Models
|
| 70 |
+
class VideoGenerationRequest(BaseModel):
|
| 71 |
+
prompt: Any # Can be string (legacy) or dict/object (structured JSON)
|
| 72 |
+
imageUrls: Optional[List[str]] = []
|
| 73 |
+
model: Optional[str] = "veo3_fast"
|
| 74 |
+
aspectRatio: Optional[str] = "9:16"
|
| 75 |
+
generationType: Optional[str] = None
|
| 76 |
+
seeds: Optional[int] = None # Seed for consistent lighting/style (e.g., 12005)
|
| 77 |
+
# Enforce audio by default (product decision)
|
| 78 |
+
voiceType: Optional[str] = 'Deep' # Voice type for audio generation (e.g., "Deep", "Warm", "Crisp", "None")
|
| 79 |
+
# Veo clip length (seconds). If omitted, providers often default to 8s.
|
| 80 |
+
durationSeconds: Optional[int] = None
|
| 81 |
+
|
| 82 |
+
class VideoExtendRequest(BaseModel):
|
| 83 |
+
taskId: str
|
| 84 |
+
prompt: Any # Can be string or structured JSON
|
| 85 |
+
seeds: Optional[int] = None
|
| 86 |
+
watermark: Optional[str] = None
|
| 87 |
+
voiceType: Optional[str] = None # Voice type for audio generation
|
| 88 |
+
|
| 89 |
+
class VideoGenerationResponse(BaseModel):
|
| 90 |
+
taskId: str
|
| 91 |
+
status: str
|
| 92 |
+
|
| 93 |
+
class CallbackData(BaseModel):
|
| 94 |
+
code: int
|
| 95 |
+
msg: str
|
| 96 |
+
data: Optional[Dict[str, Any]] = None
|
| 97 |
+
|
| 98 |
+
# Helper functions
|
| 99 |
+
def get_kie_api_key():
|
| 100 |
+
"""Get KIE API key from environment"""
|
| 101 |
+
api_key = os.getenv('KIE_API_KEY')
|
| 102 |
+
if not api_key:
|
| 103 |
+
raise HTTPException(
|
| 104 |
+
status_code=500,
|
| 105 |
+
detail="KIE_API_KEY not configured on server."
|
| 106 |
+
)
|
| 107 |
+
return api_key
|
| 108 |
+
|
| 109 |
+
async def send_sse_event(task_id: str, data: dict):
|
| 110 |
+
"""Send Server-Sent Event to connected client"""
|
| 111 |
+
if task_id in sse_clients:
|
| 112 |
+
queue = sse_clients[task_id]
|
| 113 |
+
await queue.put(data)
|
| 114 |
+
|
| 115 |
+
# Endpoints
|
| 116 |
+
@router.post("/veo/generate", response_model=VideoGenerationResponse)
|
| 117 |
+
async def generate_video(request: VideoGenerationRequest, req: Request):
|
| 118 |
+
"""
|
| 119 |
+
Generate video using KIE Veo 3.1 API
|
| 120 |
+
Supports text-to-video and image-to-video generation
|
| 121 |
+
"""
|
| 122 |
+
try:
|
| 123 |
+
api_key = get_kie_api_key()
|
| 124 |
+
|
| 125 |
+
# Build public URL for callback
|
| 126 |
+
public_url = get_public_base_url()
|
| 127 |
+
callback_url = f"{public_url}/api/veo/callback"
|
| 128 |
+
|
| 129 |
+
# Process image URLs
|
| 130 |
+
public_image_urls = []
|
| 131 |
+
if request.imageUrls:
|
| 132 |
+
print(f"📷 Processing {len(request.imageUrls)} images...")
|
| 133 |
+
for image_url in request.imageUrls:
|
| 134 |
+
# If it's already a public URL, use it as-is
|
| 135 |
+
if image_url.startswith(('http://', 'https://')):
|
| 136 |
+
print(f" Using external URL: {image_url}")
|
| 137 |
+
public_image_urls.append(image_url)
|
| 138 |
+
else:
|
| 139 |
+
# Compress and host the data URL
|
| 140 |
+
hosted_url = await compress_and_store_image(image_url, public_url)
|
| 141 |
+
print(f" Hosted image: {hosted_url}")
|
| 142 |
+
public_image_urls.append(hosted_url)
|
| 143 |
+
|
| 144 |
+
# Determine generation type
|
| 145 |
+
generation_type = request.generationType
|
| 146 |
+
if not generation_type:
|
| 147 |
+
generation_type = "FIRST_AND_LAST_FRAMES_2_VIDEO" if public_image_urls else "TEXT_2_VIDEO"
|
| 148 |
+
|
| 149 |
+
# Log prompt format and seed
|
| 150 |
+
if isinstance(request.prompt, dict):
|
| 151 |
+
print(f"📝 Sending structured JSON prompt to Veo 3.1")
|
| 152 |
+
else:
|
| 153 |
+
print(f"📝 Sending text prompt to Veo 3.1")
|
| 154 |
+
|
| 155 |
+
if request.seeds:
|
| 156 |
+
print(f"�� Using seed: {request.seeds} (warm, flattering lighting)")
|
| 157 |
+
|
| 158 |
+
# Call KIE API
|
| 159 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 160 |
+
payload = {
|
| 161 |
+
"prompt": request.prompt, # Can be string or structured JSON object
|
| 162 |
+
"imageUrls": public_image_urls,
|
| 163 |
+
"model": request.model,
|
| 164 |
+
"aspectRatio": request.aspectRatio,
|
| 165 |
+
"generationType": generation_type,
|
| 166 |
+
"enableTranslation": True,
|
| 167 |
+
"callBackUrl": callback_url
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
# Add optional seed parameter
|
| 171 |
+
if request.seeds is not None:
|
| 172 |
+
payload["seeds"] = request.seeds
|
| 173 |
+
|
| 174 |
+
# Enforce audio for all generations. Normalize any explicit "None" to default.
|
| 175 |
+
voice = request.voiceType or 'Deep'
|
| 176 |
+
if isinstance(voice, str) and voice.lower() == 'none':
|
| 177 |
+
voice = 'Deep'
|
| 178 |
+
payload["voiceType"] = voice
|
| 179 |
+
print(f"🎤 Enforcing audio (voiceType: {voice})")
|
| 180 |
+
|
| 181 |
+
clip_sec = _normalize_veo_duration_seconds(request.durationSeconds)
|
| 182 |
+
if clip_sec is not None:
|
| 183 |
+
# Google / KIE gateways commonly accept string seconds for Veo
|
| 184 |
+
payload["durationSeconds"] = str(clip_sec)
|
| 185 |
+
print(f"⏱ Requested clip duration: {clip_sec}s")
|
| 186 |
+
|
| 187 |
+
response = await client.post(
|
| 188 |
+
f"{KIE_API_BASE}/api/v1/veo/generate",
|
| 189 |
+
headers={
|
| 190 |
+
"Authorization": f"Bearer {api_key}",
|
| 191 |
+
"Content-Type": "application/json"
|
| 192 |
+
},
|
| 193 |
+
json=payload
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Log raw response for debugging
|
| 197 |
+
print(f"📡 KIE API response status: {response.status_code}")
|
| 198 |
+
|
| 199 |
+
# Check HTTP status first
|
| 200 |
+
if response.status_code != 200:
|
| 201 |
+
error_text = response.text
|
| 202 |
+
content_type = response.headers.get('content-type', '').lower()
|
| 203 |
+
|
| 204 |
+
# Handle HTML error responses (like 502 Bad Gateway pages)
|
| 205 |
+
if 'text/html' in content_type or error_text.strip().startswith('<!DOCTYPE') or error_text.strip().startswith('<html'):
|
| 206 |
+
# Extract meaningful error from HTML if possible
|
| 207 |
+
error_message = f"KIE API service unavailable (HTTP {response.status_code})"
|
| 208 |
+
|
| 209 |
+
# Try to extract title or error message from HTML
|
| 210 |
+
if '<title>' in error_text:
|
| 211 |
+
import re
|
| 212 |
+
title_match = re.search(r'<title>(.*?)</title>', error_text, re.IGNORECASE | re.DOTALL)
|
| 213 |
+
if title_match:
|
| 214 |
+
title = title_match.group(1).strip()
|
| 215 |
+
# Extract just the error part (e.g., "502: Bad gateway" from "kie.ai | 502: Bad gateway")
|
| 216 |
+
if ':' in title:
|
| 217 |
+
error_message = f"KIE API error: {title.split('|')[-1].strip()}"
|
| 218 |
+
else:
|
| 219 |
+
error_message = f"KIE API error: {title}"
|
| 220 |
+
|
| 221 |
+
print(f"❌ KIE API HTTP error: {response.status_code} - {error_message}")
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=502, # Bad Gateway - the KIE API is down/unavailable
|
| 224 |
+
detail=error_message
|
| 225 |
+
)
|
| 226 |
+
else:
|
| 227 |
+
# Non-HTML error response, try to extract JSON error if possible
|
| 228 |
+
try:
|
| 229 |
+
error_data = response.json()
|
| 230 |
+
error_message = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {response.status_code})"
|
| 231 |
+
except (json.JSONDecodeError, ValueError):
|
| 232 |
+
# Not JSON, use text (truncated)
|
| 233 |
+
error_message = error_text[:200] if len(error_text) > 200 else error_text
|
| 234 |
+
|
| 235 |
+
print(f"❌ KIE API HTTP error: {response.status_code} - {error_message[:200]}")
|
| 236 |
+
raise HTTPException(
|
| 237 |
+
status_code=response.status_code,
|
| 238 |
+
detail=f"KIE API error: {error_message}"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
result = response.json()
|
| 242 |
+
print(f"📡 KIE API result code: {result.get('code')}, msg: {result.get('msg')}")
|
| 243 |
+
|
| 244 |
+
if result.get('code') != 200:
|
| 245 |
+
raise HTTPException(
|
| 246 |
+
status_code=result.get('code', 500),
|
| 247 |
+
detail=result.get('msg', 'KIE API request failed')
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
task_id = result['data']['taskId']
|
| 251 |
+
print(f"✅ Video generation started: {task_id}")
|
| 252 |
+
|
| 253 |
+
return VideoGenerationResponse(
|
| 254 |
+
taskId=task_id,
|
| 255 |
+
status="processing"
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
except HTTPException:
|
| 259 |
+
raise
|
| 260 |
+
except httpx.HTTPStatusError as e:
|
| 261 |
+
error_text = e.response.text
|
| 262 |
+
content_type = e.response.headers.get('content-type', '').lower()
|
| 263 |
+
|
| 264 |
+
# Handle HTML error responses
|
| 265 |
+
if 'text/html' in content_type or error_text.strip().startswith('<!DOCTYPE') or error_text.strip().startswith('<html'):
|
| 266 |
+
error_msg = f"KIE API service unavailable (HTTP {e.response.status_code})"
|
| 267 |
+
# Try to extract meaningful error from HTML
|
| 268 |
+
if '<title>' in error_text:
|
| 269 |
+
import re
|
| 270 |
+
title_match = re.search(r'<title>(.*?)</title>', error_text, re.IGNORECASE | re.DOTALL)
|
| 271 |
+
if title_match:
|
| 272 |
+
title = title_match.group(1).strip()
|
| 273 |
+
if ':' in title:
|
| 274 |
+
error_msg = f"KIE API error: {title.split('|')[-1].strip()}"
|
| 275 |
+
else:
|
| 276 |
+
# Try to extract JSON error if possible
|
| 277 |
+
try:
|
| 278 |
+
error_data = e.response.json()
|
| 279 |
+
error_msg = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {e.response.status_code})"
|
| 280 |
+
except (json.JSONDecodeError, ValueError):
|
| 281 |
+
error_msg = error_text[:200] if len(error_text) > 200 else error_text
|
| 282 |
+
|
| 283 |
+
print(f"❌ {error_msg}")
|
| 284 |
+
raise HTTPException(status_code=502 if 'text/html' in content_type else e.response.status_code, detail=error_msg)
|
| 285 |
+
except httpx.RequestError as e:
|
| 286 |
+
error_msg = f"KIE API request error: {type(e).__name__} - {str(e)}"
|
| 287 |
+
print(f"❌ {error_msg}")
|
| 288 |
+
raise HTTPException(status_code=502, detail=error_msg)
|
| 289 |
+
except json.JSONDecodeError as e:
|
| 290 |
+
error_msg = f"Invalid JSON response from KIE API. The service may be unavailable."
|
| 291 |
+
print(f"❌ JSON decode error: {str(e)}")
|
| 292 |
+
raise HTTPException(status_code=502, detail=error_msg)
|
| 293 |
+
except Exception as e:
|
| 294 |
+
import traceback
|
| 295 |
+
error_msg = f"{type(e).__name__}: {str(e)}"
|
| 296 |
+
print(f"❌ Video generation error: {error_msg}")
|
| 297 |
+
traceback.print_exc()
|
| 298 |
+
raise HTTPException(
|
| 299 |
+
status_code=500,
|
| 300 |
+
detail=f"Video generation request failed: {error_msg}"
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
@router.post("/veo/callback")
|
| 304 |
+
async def veo_callback(callback_data: CallbackData):
|
| 305 |
+
"""
|
| 306 |
+
Callback endpoint for KIE API
|
| 307 |
+
Receives video generation status updates
|
| 308 |
+
"""
|
| 309 |
+
try:
|
| 310 |
+
data = callback_data.data or {}
|
| 311 |
+
task_id = data.get('taskId')
|
| 312 |
+
info = data.get('info', {})
|
| 313 |
+
fallback_flag = data.get('fallbackFlag')
|
| 314 |
+
|
| 315 |
+
print(f"📥 Callback received for task {task_id}: code={callback_data.code}, msg={callback_data.msg}")
|
| 316 |
+
|
| 317 |
+
# Store result
|
| 318 |
+
video_results[task_id] = {
|
| 319 |
+
'code': callback_data.code,
|
| 320 |
+
'msg': callback_data.msg,
|
| 321 |
+
'taskId': task_id,
|
| 322 |
+
'info': info,
|
| 323 |
+
'fallbackFlag': fallback_flag,
|
| 324 |
+
'timestamp': datetime.now().timestamp()
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
# Send SSE update to client
|
| 328 |
+
if callback_data.code == 200 and info:
|
| 329 |
+
await send_sse_event(task_id, {
|
| 330 |
+
'status': 'succeeded',
|
| 331 |
+
'url': info.get('resultUrls', [None])[0],
|
| 332 |
+
'resultUrls': info.get('resultUrls', []),
|
| 333 |
+
'originUrls': info.get('originUrls', []),
|
| 334 |
+
'resolution': info.get('resolution'),
|
| 335 |
+
'fallbackFlag': fallback_flag
|
| 336 |
+
})
|
| 337 |
+
else:
|
| 338 |
+
# Include both code and message for proper error handling
|
| 339 |
+
# This format matches what veo_error_handler.py expects
|
| 340 |
+
await send_sse_event(task_id, {
|
| 341 |
+
'status': 'failed',
|
| 342 |
+
'error': callback_data.msg, # Legacy field
|
| 343 |
+
'message': callback_data.msg, # For error handler
|
| 344 |
+
'code': callback_data.code # HTTP or API error code
|
| 345 |
+
})
|
| 346 |
+
|
| 347 |
+
# Clean up old results
|
| 348 |
+
cleanup_old_results()
|
| 349 |
+
|
| 350 |
+
return JSONResponse(
|
| 351 |
+
status_code=200,
|
| 352 |
+
content={'code': 200, 'msg': 'success'}
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
except Exception as e:
|
| 356 |
+
print(f"❌ Callback processing error: {str(e)}")
|
| 357 |
+
raise HTTPException(
|
| 358 |
+
status_code=500,
|
| 359 |
+
detail="Failed to process callback"
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@router.post("/veo/extend", response_model=VideoGenerationResponse)
|
| 364 |
+
async def extend_video(request: VideoExtendRequest):
|
| 365 |
+
"""
|
| 366 |
+
Extend an existing video using KIE Veo 3.1 extend API
|
| 367 |
+
Takes an existing taskId and extends it with new prompt
|
| 368 |
+
"""
|
| 369 |
+
try:
|
| 370 |
+
api_key = get_kie_api_key()
|
| 371 |
+
|
| 372 |
+
# Build public URL for callback
|
| 373 |
+
public_url = get_public_base_url()
|
| 374 |
+
callback_url = f"{public_url}/api/veo/callback"
|
| 375 |
+
|
| 376 |
+
print(f"🎬 Extending video from task: {request.taskId}")
|
| 377 |
+
|
| 378 |
+
# Log prompt format and seed
|
| 379 |
+
if isinstance(request.prompt, dict):
|
| 380 |
+
print(f"📝 Extending with structured JSON prompt")
|
| 381 |
+
else:
|
| 382 |
+
print(f"📝 Extending with text prompt")
|
| 383 |
+
|
| 384 |
+
if request.seeds:
|
| 385 |
+
print(f"🎲 Using seed: {request.seeds} (consistent lighting)")
|
| 386 |
+
|
| 387 |
+
# Call KIE extend API
|
| 388 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 389 |
+
payload = {
|
| 390 |
+
"taskId": request.taskId,
|
| 391 |
+
"prompt": request.prompt,
|
| 392 |
+
"callBackUrl": callback_url
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
# Add optional parameters
|
| 396 |
+
if request.seeds is not None:
|
| 397 |
+
payload["seeds"] = request.seeds
|
| 398 |
+
if request.watermark:
|
| 399 |
+
payload["watermark"] = request.watermark
|
| 400 |
+
if request.voiceType and request.voiceType.lower() != "none":
|
| 401 |
+
payload["voiceType"] = request.voiceType
|
| 402 |
+
print(f"🎤 Using voice type: {request.voiceType}")
|
| 403 |
+
else:
|
| 404 |
+
print(f"🔇 No voice/audio requested (voiceType: {request.voiceType})")
|
| 405 |
+
|
| 406 |
+
response = await client.post(
|
| 407 |
+
f"{KIE_API_BASE}/api/v1/veo/extend",
|
| 408 |
+
headers={
|
| 409 |
+
"Authorization": f"Bearer {api_key}",
|
| 410 |
+
"Content-Type": "application/json"
|
| 411 |
+
},
|
| 412 |
+
json=payload
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
# Check for HTML error responses
|
| 416 |
+
if response.status_code != 200:
|
| 417 |
+
error_text = response.text
|
| 418 |
+
content_type = response.headers.get('content-type', '').lower()
|
| 419 |
+
|
| 420 |
+
if 'text/html' in content_type or error_text.strip().startswith('<!DOCTYPE') or error_text.strip().startswith('<html'):
|
| 421 |
+
error_message = f"KIE API service unavailable (HTTP {response.status_code})"
|
| 422 |
+
if '<title>' in error_text:
|
| 423 |
+
import re
|
| 424 |
+
title_match = re.search(r'<title>(.*?)</title>', error_text, re.IGNORECASE | re.DOTALL)
|
| 425 |
+
if title_match:
|
| 426 |
+
title = title_match.group(1).strip()
|
| 427 |
+
if ':' in title:
|
| 428 |
+
error_message = f"KIE API error: {title.split('|')[-1].strip()}"
|
| 429 |
+
raise HTTPException(status_code=502, detail=error_message)
|
| 430 |
+
|
| 431 |
+
result = response.json()
|
| 432 |
+
|
| 433 |
+
if result.get('code') != 200:
|
| 434 |
+
raise HTTPException(
|
| 435 |
+
status_code=result.get('code', 500),
|
| 436 |
+
detail=result.get('msg', 'KIE extend API request failed')
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
new_task_id = result['data']['taskId']
|
| 440 |
+
print(f"✅ Video extension started: {new_task_id}")
|
| 441 |
+
|
| 442 |
+
return VideoGenerationResponse(
|
| 443 |
+
taskId=new_task_id,
|
| 444 |
+
status="processing"
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
except HTTPException:
|
| 448 |
+
raise
|
| 449 |
+
except httpx.HTTPStatusError as e:
|
| 450 |
+
error_text = e.response.text
|
| 451 |
+
content_type = e.response.headers.get('content-type', '').lower()
|
| 452 |
+
|
| 453 |
+
if 'text/html' in content_type or error_text.strip().startswith('<!DOCTYPE') or error_text.strip().startswith('<html'):
|
| 454 |
+
error_msg = f"KIE API service unavailable (HTTP {e.response.status_code})"
|
| 455 |
+
if '<title>' in error_text:
|
| 456 |
+
import re
|
| 457 |
+
title_match = re.search(r'<title>(.*?)</title>', error_text, re.IGNORECASE | re.DOTALL)
|
| 458 |
+
if title_match:
|
| 459 |
+
title = title_match.group(1).strip()
|
| 460 |
+
if ':' in title:
|
| 461 |
+
error_msg = f"KIE API error: {title.split('|')[-1].strip()}"
|
| 462 |
+
else:
|
| 463 |
+
try:
|
| 464 |
+
error_data = e.response.json()
|
| 465 |
+
error_msg = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {e.response.status_code})"
|
| 466 |
+
except (json.JSONDecodeError, ValueError):
|
| 467 |
+
error_msg = error_text[:200] if len(error_text) > 200 else error_text
|
| 468 |
+
|
| 469 |
+
print(f"❌ {error_msg}")
|
| 470 |
+
raise HTTPException(status_code=502 if 'text/html' in content_type else e.response.status_code, detail=error_msg)
|
| 471 |
+
except httpx.RequestError as e:
|
| 472 |
+
error_msg = f"KIE API request error: {type(e).__name__} - {str(e)}"
|
| 473 |
+
print(f"❌ {error_msg}")
|
| 474 |
+
raise HTTPException(status_code=502, detail=error_msg)
|
| 475 |
+
except json.JSONDecodeError as e:
|
| 476 |
+
error_msg = f"Invalid JSON response from KIE API. The service may be unavailable."
|
| 477 |
+
print(f"❌ JSON decode error: {str(e)}")
|
| 478 |
+
raise HTTPException(status_code=502, detail=error_msg)
|
| 479 |
+
except Exception as e:
|
| 480 |
+
import traceback
|
| 481 |
+
error_msg = f"{type(e).__name__}: {str(e)}"
|
| 482 |
+
print(f"❌ Video extension error: {error_msg}")
|
| 483 |
+
traceback.print_exc()
|
| 484 |
+
raise HTTPException(
|
| 485 |
+
status_code=500,
|
| 486 |
+
detail=f"Video extension error: {error_msg}"
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
@router.get("/veo/events/{task_id}")
|
| 491 |
+
async def sse_events(task_id: str):
|
| 492 |
+
"""
|
| 493 |
+
Server-Sent Events endpoint for real-time updates
|
| 494 |
+
"""
|
| 495 |
+
async def event_generator():
|
| 496 |
+
# Create queue for this client
|
| 497 |
+
queue = asyncio.Queue()
|
| 498 |
+
sse_clients[task_id] = queue
|
| 499 |
+
|
| 500 |
+
print(f"🔌 SSE client connected for task {task_id}")
|
| 501 |
+
|
| 502 |
+
try:
|
| 503 |
+
# Check if result already exists
|
| 504 |
+
if task_id in video_results:
|
| 505 |
+
result = video_results[task_id]
|
| 506 |
+
if result['code'] == 200 and result.get('info'):
|
| 507 |
+
info = result['info']
|
| 508 |
+
event_data = {
|
| 509 |
+
'status': 'succeeded',
|
| 510 |
+
'url': info.get('resultUrls', [None])[0],
|
| 511 |
+
'resultUrls': info.get('resultUrls', []),
|
| 512 |
+
'originUrls': info.get('originUrls', []),
|
| 513 |
+
'resolution': info.get('resolution'),
|
| 514 |
+
'fallbackFlag': result.get('fallbackFlag')
|
| 515 |
+
}
|
| 516 |
+
else:
|
| 517 |
+
event_data = {
|
| 518 |
+
'status': 'failed',
|
| 519 |
+
'error': result['msg'],
|
| 520 |
+
'code': result['code']
|
| 521 |
+
}
|
| 522 |
+
yield f"data: {json.dumps(event_data)}\n\n"
|
| 523 |
+
|
| 524 |
+
# Stream events
|
| 525 |
+
while True:
|
| 526 |
+
data = await queue.get()
|
| 527 |
+
yield f"data: {json.dumps(data)}\n\n"
|
| 528 |
+
|
| 529 |
+
except asyncio.CancelledError:
|
| 530 |
+
print(f"🔌 SSE client disconnected for task {task_id}")
|
| 531 |
+
finally:
|
| 532 |
+
if task_id in sse_clients:
|
| 533 |
+
del sse_clients[task_id]
|
| 534 |
+
|
| 535 |
+
return StreamingResponse(
|
| 536 |
+
event_generator(),
|
| 537 |
+
media_type="text/event-stream",
|
| 538 |
+
headers={
|
| 539 |
+
"Cache-Control": "no-cache",
|
| 540 |
+
"Connection": "keep-alive"
|
| 541 |
+
}
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
@router.get("/veo/status/{task_id}")
|
| 545 |
+
async def get_video_status(task_id: str):
|
| 546 |
+
"""
|
| 547 |
+
Get video generation status from KIE API
|
| 548 |
+
"""
|
| 549 |
+
try:
|
| 550 |
+
api_key = get_kie_api_key()
|
| 551 |
+
|
| 552 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 553 |
+
response = await client.get(
|
| 554 |
+
f"{KIE_API_BASE}/api/v1/veo/video/{task_id}",
|
| 555 |
+
headers={
|
| 556 |
+
"Authorization": f"Bearer {api_key}"
|
| 557 |
+
}
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
# Check for HTML error responses
|
| 561 |
+
if response.status_code != 200:
|
| 562 |
+
error_text = response.text
|
| 563 |
+
content_type = response.headers.get('content-type', '').lower()
|
| 564 |
+
|
| 565 |
+
if 'text/html' in content_type or error_text.strip().startswith('<!DOCTYPE') or error_text.strip().startswith('<html'):
|
| 566 |
+
error_message = f"KIE API service unavailable (HTTP {response.status_code})"
|
| 567 |
+
if '<title>' in error_text:
|
| 568 |
+
import re
|
| 569 |
+
title_match = re.search(r'<title>(.*?)</title>', error_text, re.IGNORECASE | re.DOTALL)
|
| 570 |
+
if title_match:
|
| 571 |
+
title = title_match.group(1).strip()
|
| 572 |
+
if ':' in title:
|
| 573 |
+
error_message = f"KIE API error: {title.split('|')[-1].strip()}"
|
| 574 |
+
raise HTTPException(status_code=502, detail=error_message)
|
| 575 |
+
|
| 576 |
+
result = response.json()
|
| 577 |
+
|
| 578 |
+
if result.get('code') != 200:
|
| 579 |
+
raise HTTPException(
|
| 580 |
+
status_code=result.get('code', 500),
|
| 581 |
+
detail=result.get('msg', 'Failed to get video status')
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Transform response
|
| 585 |
+
status = result['data'].get('status')
|
| 586 |
+
video_url = result['data'].get('videoUrl')
|
| 587 |
+
|
| 588 |
+
return {
|
| 589 |
+
'status': 'succeeded' if status == 'completed' else 'failed' if status == 'failed' else 'processing',
|
| 590 |
+
'output': video_url if status == 'completed' else None,
|
| 591 |
+
'url': video_url if status == 'completed' else None
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
except HTTPException:
|
| 595 |
+
raise
|
| 596 |
+
except httpx.HTTPStatusError as e:
|
| 597 |
+
error_text = e.response.text
|
| 598 |
+
content_type = e.response.headers.get('content-type', '').lower()
|
| 599 |
+
|
| 600 |
+
if 'text/html' in content_type or error_text.strip().startswith('<!DOCTYPE') or error_text.strip().startswith('<html'):
|
| 601 |
+
error_msg = f"KIE API service unavailable (HTTP {e.response.status_code})"
|
| 602 |
+
if '<title>' in error_text:
|
| 603 |
+
import re
|
| 604 |
+
title_match = re.search(r'<title>(.*?)</title>', error_text, re.IGNORECASE | re.DOTALL)
|
| 605 |
+
if title_match:
|
| 606 |
+
title = title_match.group(1).strip()
|
| 607 |
+
if ':' in title:
|
| 608 |
+
error_msg = f"KIE API error: {title.split('|')[-1].strip()}"
|
| 609 |
+
else:
|
| 610 |
+
try:
|
| 611 |
+
error_data = e.response.json()
|
| 612 |
+
error_msg = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {e.response.status_code})"
|
| 613 |
+
except (json.JSONDecodeError, ValueError):
|
| 614 |
+
error_msg = error_text[:200] if len(error_text) > 200 else error_text
|
| 615 |
+
|
| 616 |
+
print(f"❌ {error_msg}")
|
| 617 |
+
raise HTTPException(status_code=502 if 'text/html' in content_type else e.response.status_code, detail=error_msg)
|
| 618 |
+
except httpx.RequestError as e:
|
| 619 |
+
error_msg = f"KIE API request error: {type(e).__name__} - {str(e)}"
|
| 620 |
+
print(f"❌ {error_msg}")
|
| 621 |
+
raise HTTPException(status_code=502, detail=error_msg)
|
| 622 |
+
except json.JSONDecodeError as e:
|
| 623 |
+
error_msg = f"Invalid JSON response from KIE API. The service may be unavailable."
|
| 624 |
+
print(f"❌ JSON decode error: {str(e)}")
|
| 625 |
+
raise HTTPException(status_code=502, detail=error_msg)
|
| 626 |
+
except Exception as e:
|
| 627 |
+
import traceback
|
| 628 |
+
error_msg = f"{type(e).__name__}: {str(e)}"
|
| 629 |
+
print(f"❌ Status check error: {error_msg}")
|
| 630 |
+
traceback.print_exc()
|
| 631 |
+
raise HTTPException(
|
| 632 |
+
status_code=500,
|
| 633 |
+
detail=f"Failed to check video status: {error_msg}"
|
| 634 |
+
)
|
| 635 |
+
|
| 636 |
+
@router.post("/veo/cancel/{task_id}")
|
| 637 |
+
async def cancel_video_generation(task_id: str):
|
| 638 |
+
"""
|
| 639 |
+
Cancel an ongoing video generation task
|
| 640 |
+
"""
|
| 641 |
+
try:
|
| 642 |
+
# Send cancellation event to SSE clients
|
| 643 |
+
if task_id in sse_clients:
|
| 644 |
+
queue = sse_clients[task_id]
|
| 645 |
+
await send_sse_event(task_id, {
|
| 646 |
+
'status': 'cancelled',
|
| 647 |
+
'message': 'Video generation cancelled by user'
|
| 648 |
+
})
|
| 649 |
+
# Close the SSE connection
|
| 650 |
+
del sse_clients[task_id]
|
| 651 |
+
print(f"✅ Cancelled video generation: {task_id}")
|
| 652 |
+
|
| 653 |
+
# Mark result as cancelled
|
| 654 |
+
if task_id in video_results:
|
| 655 |
+
video_results[task_id] = {
|
| 656 |
+
'code': 499, # Client Closed Request
|
| 657 |
+
'msg': 'Video generation cancelled by user',
|
| 658 |
+
'taskId': task_id,
|
| 659 |
+
'timestamp': datetime.now().timestamp()
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
return JSONResponse(
|
| 663 |
+
status_code=200,
|
| 664 |
+
content={'code': 200, 'msg': 'Video generation cancelled', 'taskId': task_id}
|
| 665 |
+
)
|
| 666 |
+
|
| 667 |
+
except Exception as e:
|
| 668 |
+
print(f"❌ Error cancelling video generation: {str(e)}")
|
| 669 |
+
raise HTTPException(
|
| 670 |
+
status_code=500,
|
| 671 |
+
detail=f"Failed to cancel video generation: {str(e)}"
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
@router.get("/veo/download")
|
| 675 |
+
async def download_video(
|
| 676 |
+
url: str,
|
| 677 |
+
trim_seconds: Optional[int] = Query(None, alias="trimSeconds"),
|
| 678 |
+
):
|
| 679 |
+
"""
|
| 680 |
+
Download video from external URL
|
| 681 |
+
Proxies the video stream to avoid CORS issues.
|
| 682 |
+
Optional trimSeconds (4, 6, or 8): re-encode first N seconds with ffmpeg so
|
| 683 |
+
clip length matches the shot plan when the provider still returns a longer file.
|
| 684 |
+
"""
|
| 685 |
+
if not url:
|
| 686 |
+
raise HTTPException(status_code=400, detail="Missing url query parameter")
|
| 687 |
+
|
| 688 |
+
trim_n = _normalize_veo_duration_seconds(trim_seconds)
|
| 689 |
+
if trim_seconds is not None and trim_n is None:
|
| 690 |
+
raise HTTPException(
|
| 691 |
+
status_code=400,
|
| 692 |
+
detail="trimSeconds must be 4, 6, or 8",
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
try:
|
| 696 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 697 |
+
response = await client.get(url)
|
| 698 |
+
if response.status_code != 200:
|
| 699 |
+
raise HTTPException(
|
| 700 |
+
status_code=response.status_code,
|
| 701 |
+
detail="Failed to download asset"
|
| 702 |
+
)
|
| 703 |
+
body = response.content
|
| 704 |
+
media_type = response.headers.get('content-type', 'video/mp4')
|
| 705 |
+
|
| 706 |
+
if trim_n is None:
|
| 707 |
+
return Response(
|
| 708 |
+
content=body,
|
| 709 |
+
media_type=media_type,
|
| 710 |
+
headers={
|
| 711 |
+
'Content-Disposition': 'attachment; filename="video.mp4"',
|
| 712 |
+
'Content-Length': str(len(body)),
|
| 713 |
+
},
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
if not shutil.which('ffmpeg'):
|
| 717 |
+
print("⚠️ trimSeconds requested but ffmpeg not found; returning full file")
|
| 718 |
+
return Response(
|
| 719 |
+
content=body,
|
| 720 |
+
media_type=media_type,
|
| 721 |
+
headers={
|
| 722 |
+
'Content-Disposition': 'attachment; filename="video.mp4"',
|
| 723 |
+
'Content-Length': str(len(body)),
|
| 724 |
+
},
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 728 |
+
src = Path(tmp) / "in.mp4"
|
| 729 |
+
dst = Path(tmp) / "out.mp4"
|
| 730 |
+
src.write_bytes(body)
|
| 731 |
+
try:
|
| 732 |
+
_ffmpeg_trim_to_seconds(src, dst, trim_n)
|
| 733 |
+
except Exception as e:
|
| 734 |
+
print(f"❌ ffmpeg trim failed: {e}; returning full file")
|
| 735 |
+
return Response(
|
| 736 |
+
content=body,
|
| 737 |
+
media_type=media_type,
|
| 738 |
+
headers={
|
| 739 |
+
'Content-Disposition': 'attachment; filename="video.mp4"',
|
| 740 |
+
'Content-Length': str(len(body)),
|
| 741 |
+
},
|
| 742 |
+
)
|
| 743 |
+
out = dst.read_bytes()
|
| 744 |
+
return Response(
|
| 745 |
+
content=out,
|
| 746 |
+
media_type='video/mp4',
|
| 747 |
+
headers={
|
| 748 |
+
'Content-Disposition': 'attachment; filename="video.mp4"',
|
| 749 |
+
'Content-Length': str(len(out)),
|
| 750 |
+
},
|
| 751 |
+
)
|
| 752 |
+
|
| 753 |
+
except HTTPException:
|
| 754 |
+
raise
|
| 755 |
+
except Exception as e:
|
| 756 |
+
print(f"❌ Download error: {str(e)}")
|
| 757 |
+
raise HTTPException(
|
| 758 |
+
status_code=500,
|
| 759 |
+
detail=f"Failed to download asset: {str(e)}"
|
| 760 |
+
)
|
| 761 |
+
|
backend/main.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Product Showcase Studio — FastAPI backend.
|
| 3 |
+
Video pipeline aligned with python-backend (KIE Veo, image hosting, merge).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
|
| 13 |
+
from api.video_generation import router as video_router
|
| 14 |
+
from api.seedance_generation import router as seedance_router
|
| 15 |
+
from api.image_service import router as image_router
|
| 16 |
+
from api.video_export import router as export_router
|
| 17 |
+
from api.showcase_prompts import router as showcase_router
|
| 18 |
+
from api.scraper_routes import router as scraper_router
|
| 19 |
+
from api.gpt_image_frames import router as gpt_image_router
|
| 20 |
+
from utils.env_load import load_app_env
|
| 21 |
+
from utils.public_url import get_public_base_url, get_server_port_str
|
| 22 |
+
from utils.storage import cleanup_old_files
|
| 23 |
+
|
| 24 |
+
load_app_env()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@asynccontextmanager
|
| 28 |
+
async def lifespan(app: FastAPI):
|
| 29 |
+
print("Starting Product Showcase API…")
|
| 30 |
+
os.makedirs("storage/images", exist_ok=True)
|
| 31 |
+
os.makedirs("storage/videos", exist_ok=True)
|
| 32 |
+
public = get_public_base_url()
|
| 33 |
+
print(f"Public URL for callbacks/hosted images: {public}")
|
| 34 |
+
if not os.getenv("KIE_API_KEY"):
|
| 35 |
+
if os.getenv("REPLICATE_API_TOKEN") or os.getenv("REPLICATE_API_KEY"):
|
| 36 |
+
print("Info: KIE_API_KEY not set — Seedance can use Replicate fallback when token is set.")
|
| 37 |
+
else:
|
| 38 |
+
print("Warning: KIE_API_KEY not set — KIE video generation will fail (set REPLICATE_API_TOKEN for Seedance fallback).")
|
| 39 |
+
if not os.getenv("OPENAI_API_KEY"):
|
| 40 |
+
print("Info: OPENAI_API_KEY not set — shot plan uses built-in cinematic template; GPT Image frames disabled.")
|
| 41 |
+
else:
|
| 42 |
+
print(f"GPT Image model for first frames: {os.getenv('GPT_IMAGE_MODEL', 'gpt-image-1.5')}")
|
| 43 |
+
yield
|
| 44 |
+
cleanup_old_files()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
app = FastAPI(
|
| 48 |
+
title="Product Showcase API",
|
| 49 |
+
description="Cinematic product videos: shot planning + KIE Veo / Seedance 2 + export",
|
| 50 |
+
version="1.0.0",
|
| 51 |
+
lifespan=lifespan,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
origins = [
|
| 55 |
+
"http://localhost:5173",
|
| 56 |
+
"http://127.0.0.1:5173",
|
| 57 |
+
"http://localhost:3000",
|
| 58 |
+
"http://127.0.0.1:3000",
|
| 59 |
+
]
|
| 60 |
+
extra = (os.getenv("CORS_ALLOWED_ORIGINS") or "").strip()
|
| 61 |
+
if extra:
|
| 62 |
+
origins = [o.strip().rstrip("/") for o in extra.split(",") if o.strip()]
|
| 63 |
+
|
| 64 |
+
app.add_middleware(
|
| 65 |
+
CORSMiddleware,
|
| 66 |
+
allow_origins=origins,
|
| 67 |
+
allow_credentials=True,
|
| 68 |
+
allow_methods=["*"],
|
| 69 |
+
allow_headers=["*"],
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
app.include_router(showcase_router, prefix="/api")
|
| 73 |
+
app.include_router(scraper_router, prefix="/api")
|
| 74 |
+
app.include_router(gpt_image_router, prefix="/api")
|
| 75 |
+
app.include_router(video_router, prefix="/api")
|
| 76 |
+
app.include_router(seedance_router, prefix="/api")
|
| 77 |
+
app.include_router(image_router, prefix="/api")
|
| 78 |
+
app.include_router(export_router, prefix="/api")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.get("/health")
|
| 82 |
+
def health():
|
| 83 |
+
return {
|
| 84 |
+
"status": "healthy",
|
| 85 |
+
"service": "product-showcase",
|
| 86 |
+
"kie_configured": bool(os.getenv("KIE_API_KEY")),
|
| 87 |
+
"replicate_configured": bool(
|
| 88 |
+
(os.getenv("REPLICATE_API_TOKEN") or os.getenv("REPLICATE_API_KEY") or "").strip()
|
| 89 |
+
),
|
| 90 |
+
"openai_configured": bool(os.getenv("OPENAI_API_KEY")),
|
| 91 |
+
"gpt_image_model": os.getenv("GPT_IMAGE_MODEL", "gpt-image-1.5"),
|
| 92 |
+
"ffmpeg_available": bool(shutil.which("ffmpeg")),
|
| 93 |
+
"ffprobe_available": bool(shutil.which("ffprobe")),
|
| 94 |
+
"public_base_url": get_public_base_url(),
|
| 95 |
+
"server_port": int(get_server_port_str()),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
import uvicorn
|
| 101 |
+
|
| 102 |
+
port = int(get_server_port_str())
|
| 103 |
+
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.32.0
|
| 3 |
+
python-multipart>=0.0.9
|
| 4 |
+
python-dotenv>=1.0.0
|
| 5 |
+
httpx>=0.27.0
|
| 6 |
+
pillow>=10.0.0
|
| 7 |
+
openai>=1.40.0
|
| 8 |
+
requests>=2.31.0
|
| 9 |
+
beautifulsoup4>=4.12.0
|
backend/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package
|
backend/utils/env_load.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load .env from backend/ and repo root so one file works from either cwd."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
| 10 |
+
_REPO_ROOT = _BACKEND_DIR.parent
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_app_env() -> None:
|
| 14 |
+
"""Later paths override earlier (most specific wins)."""
|
| 15 |
+
for path in (
|
| 16 |
+
_REPO_ROOT / ".env",
|
| 17 |
+
_REPO_ROOT / ".env.local",
|
| 18 |
+
_BACKEND_DIR / ".env",
|
| 19 |
+
_BACKEND_DIR / ".env.local",
|
| 20 |
+
):
|
| 21 |
+
if path.is_file():
|
| 22 |
+
load_dotenv(path, override=True)
|
backend/utils/image_processor.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image Processing Utilities
|
| 3 |
+
Handles image compression, conversion, and storage
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import re
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
from PIL import Image
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from utils.storage import temp_images
|
| 15 |
+
|
| 16 |
+
async def compress_and_store_image(
|
| 17 |
+
data_url: str,
|
| 18 |
+
public_url: str,
|
| 19 |
+
max_width: int = 960,
|
| 20 |
+
max_height: int = 540,
|
| 21 |
+
quality: int = 70
|
| 22 |
+
) -> str:
|
| 23 |
+
"""
|
| 24 |
+
Compress image from data URL and return public URL
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
data_url: Base64 data URL (data:image/...;base64,...)
|
| 28 |
+
public_url: Base URL for the server
|
| 29 |
+
max_width: Maximum width for resizing
|
| 30 |
+
max_height: Maximum height for resizing
|
| 31 |
+
quality: JPEG quality (1-100)
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Public URL to access the compressed image
|
| 35 |
+
"""
|
| 36 |
+
try:
|
| 37 |
+
# Extract base64 data from data URL
|
| 38 |
+
matches = re.match(r'^data:image/[a-zA-Z]+;base64,(.+)$', data_url)
|
| 39 |
+
if not matches:
|
| 40 |
+
raise ValueError('Invalid data URL format')
|
| 41 |
+
|
| 42 |
+
base64_data = matches.group(1)
|
| 43 |
+
image_bytes = base64.b64decode(base64_data)
|
| 44 |
+
|
| 45 |
+
# Open image with PIL
|
| 46 |
+
image = Image.open(BytesIO(image_bytes))
|
| 47 |
+
|
| 48 |
+
# Convert RGBA to RGB if necessary
|
| 49 |
+
if image.mode in ('RGBA', 'LA', 'P'):
|
| 50 |
+
background = Image.new('RGB', image.size, (255, 255, 255))
|
| 51 |
+
if image.mode == 'P':
|
| 52 |
+
image = image.convert('RGBA')
|
| 53 |
+
background.paste(image, mask=image.split()[-1] if image.mode in ('RGBA', 'LA') else None)
|
| 54 |
+
image = background
|
| 55 |
+
|
| 56 |
+
# Resize maintaining aspect ratio
|
| 57 |
+
image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
| 58 |
+
|
| 59 |
+
# Compress to JPEG
|
| 60 |
+
output = BytesIO()
|
| 61 |
+
image.save(output, format='JPEG', quality=quality, optimize=True)
|
| 62 |
+
compressed_buffer = output.getvalue()
|
| 63 |
+
|
| 64 |
+
# Generate unique ID
|
| 65 |
+
image_id = f"img_{int(datetime.now().timestamp())}_{uuid.uuid4().hex[:9]}"
|
| 66 |
+
|
| 67 |
+
# Store in memory
|
| 68 |
+
temp_images[image_id] = {
|
| 69 |
+
'buffer': compressed_buffer,
|
| 70 |
+
'timestamp': datetime.now().timestamp(),
|
| 71 |
+
'content_type': 'image/jpeg'
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Clean up old images (older than 1 hour)
|
| 75 |
+
cleanup_old_images()
|
| 76 |
+
|
| 77 |
+
# Return public URL
|
| 78 |
+
return f"{public_url}/api/images/{image_id}"
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"❌ Image compression error: {str(e)}")
|
| 82 |
+
raise
|
| 83 |
+
|
| 84 |
+
def cleanup_old_images():
|
| 85 |
+
"""Remove images older than 1 hour"""
|
| 86 |
+
current_time = datetime.now().timestamp()
|
| 87 |
+
to_remove = []
|
| 88 |
+
|
| 89 |
+
for image_id, data in temp_images.items():
|
| 90 |
+
if current_time - data['timestamp'] > 3600: # 1 hour
|
| 91 |
+
to_remove.append(image_id)
|
| 92 |
+
|
| 93 |
+
for image_id in to_remove:
|
| 94 |
+
del temp_images[image_id]
|
| 95 |
+
|
| 96 |
+
if to_remove:
|
| 97 |
+
print(f"🧹 Cleaned up {len(to_remove)} old images")
|
| 98 |
+
|
backend/utils/public_url.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single place for callback / hosted-asset base URL (KIE, image hosting, Veo)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_server_port_str() -> str:
|
| 9 |
+
return os.getenv("PORT") or os.getenv("SERVER_PORT", "4010")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_public_base_url() -> str:
|
| 13 |
+
explicit = (os.getenv("VITE_API_BASE_URL") or "").strip().rstrip("/")
|
| 14 |
+
if explicit:
|
| 15 |
+
return explicit
|
| 16 |
+
return f"http://127.0.0.1:{get_server_port_str()}"
|
backend/utils/storage.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Storage Management
|
| 3 |
+
Handles in-memory storage for images, videos, and SSE clients
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Any
|
| 7 |
+
import asyncio
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# In-memory storage
|
| 11 |
+
temp_images: Dict[str, Dict[str, Any]] = {}
|
| 12 |
+
video_results: Dict[str, Dict[str, Any]] = {}
|
| 13 |
+
sse_clients: Dict[str, asyncio.Queue] = {}
|
| 14 |
+
|
| 15 |
+
def cleanup_old_results(max_age_hours: int = 24):
|
| 16 |
+
"""
|
| 17 |
+
Clean up old video results
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
max_age_hours: Maximum age in hours before cleanup
|
| 21 |
+
"""
|
| 22 |
+
current_time = datetime.now().timestamp()
|
| 23 |
+
to_remove = []
|
| 24 |
+
|
| 25 |
+
for task_id, data in video_results.items():
|
| 26 |
+
if current_time - data['timestamp'] > (max_age_hours * 3600):
|
| 27 |
+
to_remove.append(task_id)
|
| 28 |
+
|
| 29 |
+
for task_id in to_remove:
|
| 30 |
+
del video_results[task_id]
|
| 31 |
+
|
| 32 |
+
if to_remove:
|
| 33 |
+
print(f"🧹 Cleaned up {len(to_remove)} old video results")
|
| 34 |
+
|
| 35 |
+
def cleanup_old_files():
|
| 36 |
+
"""Clean up all temporary storage on shutdown"""
|
| 37 |
+
temp_images.clear()
|
| 38 |
+
video_results.clear()
|
| 39 |
+
sse_clients.clear()
|
| 40 |
+
print("🧹 Cleared all temporary storage")
|
| 41 |
+
|
frontend/index.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Product Showcase Studio</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://api.fontshare.com/v2/css?f[]=clash-display@600,700&f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
|
| 10 |
+
</head>
|
| 11 |
+
<body class="min-h-screen">
|
| 12 |
+
<div id="root"></div>
|
| 13 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|
frontend/package-lock.json
ADDED
|
@@ -0,0 +1,2612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "product-showcase-studio",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "product-showcase-studio",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"framer-motion": "^11.15.0",
|
| 12 |
+
"react": "^19.0.0",
|
| 13 |
+
"react-dom": "^19.0.0"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@types/react": "^19.0.0",
|
| 17 |
+
"@types/react-dom": "^19.0.0",
|
| 18 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 19 |
+
"autoprefixer": "^10.4.20",
|
| 20 |
+
"postcss": "^8.4.49",
|
| 21 |
+
"tailwindcss": "^3.4.17",
|
| 22 |
+
"typescript": "~5.6.0",
|
| 23 |
+
"vite": "^6.0.0"
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
"node_modules/@alloc/quick-lru": {
|
| 27 |
+
"version": "5.2.0",
|
| 28 |
+
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
| 29 |
+
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
| 30 |
+
"dev": true,
|
| 31 |
+
"engines": {
|
| 32 |
+
"node": ">=10"
|
| 33 |
+
},
|
| 34 |
+
"funding": {
|
| 35 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
"node_modules/@babel/code-frame": {
|
| 39 |
+
"version": "7.29.0",
|
| 40 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
| 41 |
+
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
| 42 |
+
"dev": true,
|
| 43 |
+
"dependencies": {
|
| 44 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 45 |
+
"js-tokens": "^4.0.0",
|
| 46 |
+
"picocolors": "^1.1.1"
|
| 47 |
+
},
|
| 48 |
+
"engines": {
|
| 49 |
+
"node": ">=6.9.0"
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
"node_modules/@babel/compat-data": {
|
| 53 |
+
"version": "7.29.0",
|
| 54 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
| 55 |
+
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
| 56 |
+
"dev": true,
|
| 57 |
+
"engines": {
|
| 58 |
+
"node": ">=6.9.0"
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
"node_modules/@babel/core": {
|
| 62 |
+
"version": "7.29.0",
|
| 63 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 64 |
+
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 65 |
+
"dev": true,
|
| 66 |
+
"dependencies": {
|
| 67 |
+
"@babel/code-frame": "^7.29.0",
|
| 68 |
+
"@babel/generator": "^7.29.0",
|
| 69 |
+
"@babel/helper-compilation-targets": "^7.28.6",
|
| 70 |
+
"@babel/helper-module-transforms": "^7.28.6",
|
| 71 |
+
"@babel/helpers": "^7.28.6",
|
| 72 |
+
"@babel/parser": "^7.29.0",
|
| 73 |
+
"@babel/template": "^7.28.6",
|
| 74 |
+
"@babel/traverse": "^7.29.0",
|
| 75 |
+
"@babel/types": "^7.29.0",
|
| 76 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 77 |
+
"convert-source-map": "^2.0.0",
|
| 78 |
+
"debug": "^4.1.0",
|
| 79 |
+
"gensync": "^1.0.0-beta.2",
|
| 80 |
+
"json5": "^2.2.3",
|
| 81 |
+
"semver": "^6.3.1"
|
| 82 |
+
},
|
| 83 |
+
"engines": {
|
| 84 |
+
"node": ">=6.9.0"
|
| 85 |
+
},
|
| 86 |
+
"funding": {
|
| 87 |
+
"type": "opencollective",
|
| 88 |
+
"url": "https://opencollective.com/babel"
|
| 89 |
+
}
|
| 90 |
+
},
|
| 91 |
+
"node_modules/@babel/generator": {
|
| 92 |
+
"version": "7.29.1",
|
| 93 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
| 94 |
+
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
| 95 |
+
"dev": true,
|
| 96 |
+
"dependencies": {
|
| 97 |
+
"@babel/parser": "^7.29.0",
|
| 98 |
+
"@babel/types": "^7.29.0",
|
| 99 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 100 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 101 |
+
"jsesc": "^3.0.2"
|
| 102 |
+
},
|
| 103 |
+
"engines": {
|
| 104 |
+
"node": ">=6.9.0"
|
| 105 |
+
}
|
| 106 |
+
},
|
| 107 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 108 |
+
"version": "7.28.6",
|
| 109 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
| 110 |
+
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
| 111 |
+
"dev": true,
|
| 112 |
+
"dependencies": {
|
| 113 |
+
"@babel/compat-data": "^7.28.6",
|
| 114 |
+
"@babel/helper-validator-option": "^7.27.1",
|
| 115 |
+
"browserslist": "^4.24.0",
|
| 116 |
+
"lru-cache": "^5.1.1",
|
| 117 |
+
"semver": "^6.3.1"
|
| 118 |
+
},
|
| 119 |
+
"engines": {
|
| 120 |
+
"node": ">=6.9.0"
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
"node_modules/@babel/helper-globals": {
|
| 124 |
+
"version": "7.28.0",
|
| 125 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
| 126 |
+
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
| 127 |
+
"dev": true,
|
| 128 |
+
"engines": {
|
| 129 |
+
"node": ">=6.9.0"
|
| 130 |
+
}
|
| 131 |
+
},
|
| 132 |
+
"node_modules/@babel/helper-module-imports": {
|
| 133 |
+
"version": "7.28.6",
|
| 134 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
| 135 |
+
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
| 136 |
+
"dev": true,
|
| 137 |
+
"dependencies": {
|
| 138 |
+
"@babel/traverse": "^7.28.6",
|
| 139 |
+
"@babel/types": "^7.28.6"
|
| 140 |
+
},
|
| 141 |
+
"engines": {
|
| 142 |
+
"node": ">=6.9.0"
|
| 143 |
+
}
|
| 144 |
+
},
|
| 145 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 146 |
+
"version": "7.28.6",
|
| 147 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
| 148 |
+
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
| 149 |
+
"dev": true,
|
| 150 |
+
"dependencies": {
|
| 151 |
+
"@babel/helper-module-imports": "^7.28.6",
|
| 152 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 153 |
+
"@babel/traverse": "^7.28.6"
|
| 154 |
+
},
|
| 155 |
+
"engines": {
|
| 156 |
+
"node": ">=6.9.0"
|
| 157 |
+
},
|
| 158 |
+
"peerDependencies": {
|
| 159 |
+
"@babel/core": "^7.0.0"
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 163 |
+
"version": "7.28.6",
|
| 164 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
| 165 |
+
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
| 166 |
+
"dev": true,
|
| 167 |
+
"engines": {
|
| 168 |
+
"node": ">=6.9.0"
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
"node_modules/@babel/helper-string-parser": {
|
| 172 |
+
"version": "7.27.1",
|
| 173 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
| 174 |
+
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
| 175 |
+
"dev": true,
|
| 176 |
+
"engines": {
|
| 177 |
+
"node": ">=6.9.0"
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 181 |
+
"version": "7.28.5",
|
| 182 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
| 183 |
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
| 184 |
+
"dev": true,
|
| 185 |
+
"engines": {
|
| 186 |
+
"node": ">=6.9.0"
|
| 187 |
+
}
|
| 188 |
+
},
|
| 189 |
+
"node_modules/@babel/helper-validator-option": {
|
| 190 |
+
"version": "7.27.1",
|
| 191 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
| 192 |
+
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
| 193 |
+
"dev": true,
|
| 194 |
+
"engines": {
|
| 195 |
+
"node": ">=6.9.0"
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
"node_modules/@babel/helpers": {
|
| 199 |
+
"version": "7.29.2",
|
| 200 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
| 201 |
+
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
| 202 |
+
"dev": true,
|
| 203 |
+
"dependencies": {
|
| 204 |
+
"@babel/template": "^7.28.6",
|
| 205 |
+
"@babel/types": "^7.29.0"
|
| 206 |
+
},
|
| 207 |
+
"engines": {
|
| 208 |
+
"node": ">=6.9.0"
|
| 209 |
+
}
|
| 210 |
+
},
|
| 211 |
+
"node_modules/@babel/parser": {
|
| 212 |
+
"version": "7.29.2",
|
| 213 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
| 214 |
+
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
| 215 |
+
"dev": true,
|
| 216 |
+
"dependencies": {
|
| 217 |
+
"@babel/types": "^7.29.0"
|
| 218 |
+
},
|
| 219 |
+
"bin": {
|
| 220 |
+
"parser": "bin/babel-parser.js"
|
| 221 |
+
},
|
| 222 |
+
"engines": {
|
| 223 |
+
"node": ">=6.0.0"
|
| 224 |
+
}
|
| 225 |
+
},
|
| 226 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 227 |
+
"version": "7.27.1",
|
| 228 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
| 229 |
+
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
| 230 |
+
"dev": true,
|
| 231 |
+
"dependencies": {
|
| 232 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 233 |
+
},
|
| 234 |
+
"engines": {
|
| 235 |
+
"node": ">=6.9.0"
|
| 236 |
+
},
|
| 237 |
+
"peerDependencies": {
|
| 238 |
+
"@babel/core": "^7.0.0-0"
|
| 239 |
+
}
|
| 240 |
+
},
|
| 241 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 242 |
+
"version": "7.27.1",
|
| 243 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
| 244 |
+
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
| 245 |
+
"dev": true,
|
| 246 |
+
"dependencies": {
|
| 247 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 248 |
+
},
|
| 249 |
+
"engines": {
|
| 250 |
+
"node": ">=6.9.0"
|
| 251 |
+
},
|
| 252 |
+
"peerDependencies": {
|
| 253 |
+
"@babel/core": "^7.0.0-0"
|
| 254 |
+
}
|
| 255 |
+
},
|
| 256 |
+
"node_modules/@babel/template": {
|
| 257 |
+
"version": "7.28.6",
|
| 258 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
| 259 |
+
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
| 260 |
+
"dev": true,
|
| 261 |
+
"dependencies": {
|
| 262 |
+
"@babel/code-frame": "^7.28.6",
|
| 263 |
+
"@babel/parser": "^7.28.6",
|
| 264 |
+
"@babel/types": "^7.28.6"
|
| 265 |
+
},
|
| 266 |
+
"engines": {
|
| 267 |
+
"node": ">=6.9.0"
|
| 268 |
+
}
|
| 269 |
+
},
|
| 270 |
+
"node_modules/@babel/traverse": {
|
| 271 |
+
"version": "7.29.0",
|
| 272 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
| 273 |
+
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
| 274 |
+
"dev": true,
|
| 275 |
+
"dependencies": {
|
| 276 |
+
"@babel/code-frame": "^7.29.0",
|
| 277 |
+
"@babel/generator": "^7.29.0",
|
| 278 |
+
"@babel/helper-globals": "^7.28.0",
|
| 279 |
+
"@babel/parser": "^7.29.0",
|
| 280 |
+
"@babel/template": "^7.28.6",
|
| 281 |
+
"@babel/types": "^7.29.0",
|
| 282 |
+
"debug": "^4.3.1"
|
| 283 |
+
},
|
| 284 |
+
"engines": {
|
| 285 |
+
"node": ">=6.9.0"
|
| 286 |
+
}
|
| 287 |
+
},
|
| 288 |
+
"node_modules/@babel/types": {
|
| 289 |
+
"version": "7.29.0",
|
| 290 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
| 291 |
+
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
| 292 |
+
"dev": true,
|
| 293 |
+
"dependencies": {
|
| 294 |
+
"@babel/helper-string-parser": "^7.27.1",
|
| 295 |
+
"@babel/helper-validator-identifier": "^7.28.5"
|
| 296 |
+
},
|
| 297 |
+
"engines": {
|
| 298 |
+
"node": ">=6.9.0"
|
| 299 |
+
}
|
| 300 |
+
},
|
| 301 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 302 |
+
"version": "0.25.12",
|
| 303 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
| 304 |
+
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
| 305 |
+
"cpu": [
|
| 306 |
+
"ppc64"
|
| 307 |
+
],
|
| 308 |
+
"dev": true,
|
| 309 |
+
"optional": true,
|
| 310 |
+
"os": [
|
| 311 |
+
"aix"
|
| 312 |
+
],
|
| 313 |
+
"engines": {
|
| 314 |
+
"node": ">=18"
|
| 315 |
+
}
|
| 316 |
+
},
|
| 317 |
+
"node_modules/@esbuild/android-arm": {
|
| 318 |
+
"version": "0.25.12",
|
| 319 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
| 320 |
+
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
| 321 |
+
"cpu": [
|
| 322 |
+
"arm"
|
| 323 |
+
],
|
| 324 |
+
"dev": true,
|
| 325 |
+
"optional": true,
|
| 326 |
+
"os": [
|
| 327 |
+
"android"
|
| 328 |
+
],
|
| 329 |
+
"engines": {
|
| 330 |
+
"node": ">=18"
|
| 331 |
+
}
|
| 332 |
+
},
|
| 333 |
+
"node_modules/@esbuild/android-arm64": {
|
| 334 |
+
"version": "0.25.12",
|
| 335 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
| 336 |
+
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
| 337 |
+
"cpu": [
|
| 338 |
+
"arm64"
|
| 339 |
+
],
|
| 340 |
+
"dev": true,
|
| 341 |
+
"optional": true,
|
| 342 |
+
"os": [
|
| 343 |
+
"android"
|
| 344 |
+
],
|
| 345 |
+
"engines": {
|
| 346 |
+
"node": ">=18"
|
| 347 |
+
}
|
| 348 |
+
},
|
| 349 |
+
"node_modules/@esbuild/android-x64": {
|
| 350 |
+
"version": "0.25.12",
|
| 351 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
| 352 |
+
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
| 353 |
+
"cpu": [
|
| 354 |
+
"x64"
|
| 355 |
+
],
|
| 356 |
+
"dev": true,
|
| 357 |
+
"optional": true,
|
| 358 |
+
"os": [
|
| 359 |
+
"android"
|
| 360 |
+
],
|
| 361 |
+
"engines": {
|
| 362 |
+
"node": ">=18"
|
| 363 |
+
}
|
| 364 |
+
},
|
| 365 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 366 |
+
"version": "0.25.12",
|
| 367 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
| 368 |
+
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
| 369 |
+
"cpu": [
|
| 370 |
+
"arm64"
|
| 371 |
+
],
|
| 372 |
+
"dev": true,
|
| 373 |
+
"optional": true,
|
| 374 |
+
"os": [
|
| 375 |
+
"darwin"
|
| 376 |
+
],
|
| 377 |
+
"engines": {
|
| 378 |
+
"node": ">=18"
|
| 379 |
+
}
|
| 380 |
+
},
|
| 381 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 382 |
+
"version": "0.25.12",
|
| 383 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
| 384 |
+
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
| 385 |
+
"cpu": [
|
| 386 |
+
"x64"
|
| 387 |
+
],
|
| 388 |
+
"dev": true,
|
| 389 |
+
"optional": true,
|
| 390 |
+
"os": [
|
| 391 |
+
"darwin"
|
| 392 |
+
],
|
| 393 |
+
"engines": {
|
| 394 |
+
"node": ">=18"
|
| 395 |
+
}
|
| 396 |
+
},
|
| 397 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 398 |
+
"version": "0.25.12",
|
| 399 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
| 400 |
+
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
| 401 |
+
"cpu": [
|
| 402 |
+
"arm64"
|
| 403 |
+
],
|
| 404 |
+
"dev": true,
|
| 405 |
+
"optional": true,
|
| 406 |
+
"os": [
|
| 407 |
+
"freebsd"
|
| 408 |
+
],
|
| 409 |
+
"engines": {
|
| 410 |
+
"node": ">=18"
|
| 411 |
+
}
|
| 412 |
+
},
|
| 413 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 414 |
+
"version": "0.25.12",
|
| 415 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
| 416 |
+
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
| 417 |
+
"cpu": [
|
| 418 |
+
"x64"
|
| 419 |
+
],
|
| 420 |
+
"dev": true,
|
| 421 |
+
"optional": true,
|
| 422 |
+
"os": [
|
| 423 |
+
"freebsd"
|
| 424 |
+
],
|
| 425 |
+
"engines": {
|
| 426 |
+
"node": ">=18"
|
| 427 |
+
}
|
| 428 |
+
},
|
| 429 |
+
"node_modules/@esbuild/linux-arm": {
|
| 430 |
+
"version": "0.25.12",
|
| 431 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
| 432 |
+
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
| 433 |
+
"cpu": [
|
| 434 |
+
"arm"
|
| 435 |
+
],
|
| 436 |
+
"dev": true,
|
| 437 |
+
"optional": true,
|
| 438 |
+
"os": [
|
| 439 |
+
"linux"
|
| 440 |
+
],
|
| 441 |
+
"engines": {
|
| 442 |
+
"node": ">=18"
|
| 443 |
+
}
|
| 444 |
+
},
|
| 445 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 446 |
+
"version": "0.25.12",
|
| 447 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
| 448 |
+
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
| 449 |
+
"cpu": [
|
| 450 |
+
"arm64"
|
| 451 |
+
],
|
| 452 |
+
"dev": true,
|
| 453 |
+
"optional": true,
|
| 454 |
+
"os": [
|
| 455 |
+
"linux"
|
| 456 |
+
],
|
| 457 |
+
"engines": {
|
| 458 |
+
"node": ">=18"
|
| 459 |
+
}
|
| 460 |
+
},
|
| 461 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 462 |
+
"version": "0.25.12",
|
| 463 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
| 464 |
+
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
| 465 |
+
"cpu": [
|
| 466 |
+
"ia32"
|
| 467 |
+
],
|
| 468 |
+
"dev": true,
|
| 469 |
+
"optional": true,
|
| 470 |
+
"os": [
|
| 471 |
+
"linux"
|
| 472 |
+
],
|
| 473 |
+
"engines": {
|
| 474 |
+
"node": ">=18"
|
| 475 |
+
}
|
| 476 |
+
},
|
| 477 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 478 |
+
"version": "0.25.12",
|
| 479 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
| 480 |
+
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
| 481 |
+
"cpu": [
|
| 482 |
+
"loong64"
|
| 483 |
+
],
|
| 484 |
+
"dev": true,
|
| 485 |
+
"optional": true,
|
| 486 |
+
"os": [
|
| 487 |
+
"linux"
|
| 488 |
+
],
|
| 489 |
+
"engines": {
|
| 490 |
+
"node": ">=18"
|
| 491 |
+
}
|
| 492 |
+
},
|
| 493 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 494 |
+
"version": "0.25.12",
|
| 495 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
| 496 |
+
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
| 497 |
+
"cpu": [
|
| 498 |
+
"mips64el"
|
| 499 |
+
],
|
| 500 |
+
"dev": true,
|
| 501 |
+
"optional": true,
|
| 502 |
+
"os": [
|
| 503 |
+
"linux"
|
| 504 |
+
],
|
| 505 |
+
"engines": {
|
| 506 |
+
"node": ">=18"
|
| 507 |
+
}
|
| 508 |
+
},
|
| 509 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 510 |
+
"version": "0.25.12",
|
| 511 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
| 512 |
+
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
| 513 |
+
"cpu": [
|
| 514 |
+
"ppc64"
|
| 515 |
+
],
|
| 516 |
+
"dev": true,
|
| 517 |
+
"optional": true,
|
| 518 |
+
"os": [
|
| 519 |
+
"linux"
|
| 520 |
+
],
|
| 521 |
+
"engines": {
|
| 522 |
+
"node": ">=18"
|
| 523 |
+
}
|
| 524 |
+
},
|
| 525 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 526 |
+
"version": "0.25.12",
|
| 527 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
| 528 |
+
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
| 529 |
+
"cpu": [
|
| 530 |
+
"riscv64"
|
| 531 |
+
],
|
| 532 |
+
"dev": true,
|
| 533 |
+
"optional": true,
|
| 534 |
+
"os": [
|
| 535 |
+
"linux"
|
| 536 |
+
],
|
| 537 |
+
"engines": {
|
| 538 |
+
"node": ">=18"
|
| 539 |
+
}
|
| 540 |
+
},
|
| 541 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 542 |
+
"version": "0.25.12",
|
| 543 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
| 544 |
+
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
| 545 |
+
"cpu": [
|
| 546 |
+
"s390x"
|
| 547 |
+
],
|
| 548 |
+
"dev": true,
|
| 549 |
+
"optional": true,
|
| 550 |
+
"os": [
|
| 551 |
+
"linux"
|
| 552 |
+
],
|
| 553 |
+
"engines": {
|
| 554 |
+
"node": ">=18"
|
| 555 |
+
}
|
| 556 |
+
},
|
| 557 |
+
"node_modules/@esbuild/linux-x64": {
|
| 558 |
+
"version": "0.25.12",
|
| 559 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
| 560 |
+
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
| 561 |
+
"cpu": [
|
| 562 |
+
"x64"
|
| 563 |
+
],
|
| 564 |
+
"dev": true,
|
| 565 |
+
"optional": true,
|
| 566 |
+
"os": [
|
| 567 |
+
"linux"
|
| 568 |
+
],
|
| 569 |
+
"engines": {
|
| 570 |
+
"node": ">=18"
|
| 571 |
+
}
|
| 572 |
+
},
|
| 573 |
+
"node_modules/@esbuild/netbsd-arm64": {
|
| 574 |
+
"version": "0.25.12",
|
| 575 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
| 576 |
+
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
| 577 |
+
"cpu": [
|
| 578 |
+
"arm64"
|
| 579 |
+
],
|
| 580 |
+
"dev": true,
|
| 581 |
+
"optional": true,
|
| 582 |
+
"os": [
|
| 583 |
+
"netbsd"
|
| 584 |
+
],
|
| 585 |
+
"engines": {
|
| 586 |
+
"node": ">=18"
|
| 587 |
+
}
|
| 588 |
+
},
|
| 589 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 590 |
+
"version": "0.25.12",
|
| 591 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
| 592 |
+
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
| 593 |
+
"cpu": [
|
| 594 |
+
"x64"
|
| 595 |
+
],
|
| 596 |
+
"dev": true,
|
| 597 |
+
"optional": true,
|
| 598 |
+
"os": [
|
| 599 |
+
"netbsd"
|
| 600 |
+
],
|
| 601 |
+
"engines": {
|
| 602 |
+
"node": ">=18"
|
| 603 |
+
}
|
| 604 |
+
},
|
| 605 |
+
"node_modules/@esbuild/openbsd-arm64": {
|
| 606 |
+
"version": "0.25.12",
|
| 607 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
| 608 |
+
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
| 609 |
+
"cpu": [
|
| 610 |
+
"arm64"
|
| 611 |
+
],
|
| 612 |
+
"dev": true,
|
| 613 |
+
"optional": true,
|
| 614 |
+
"os": [
|
| 615 |
+
"openbsd"
|
| 616 |
+
],
|
| 617 |
+
"engines": {
|
| 618 |
+
"node": ">=18"
|
| 619 |
+
}
|
| 620 |
+
},
|
| 621 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 622 |
+
"version": "0.25.12",
|
| 623 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
| 624 |
+
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
| 625 |
+
"cpu": [
|
| 626 |
+
"x64"
|
| 627 |
+
],
|
| 628 |
+
"dev": true,
|
| 629 |
+
"optional": true,
|
| 630 |
+
"os": [
|
| 631 |
+
"openbsd"
|
| 632 |
+
],
|
| 633 |
+
"engines": {
|
| 634 |
+
"node": ">=18"
|
| 635 |
+
}
|
| 636 |
+
},
|
| 637 |
+
"node_modules/@esbuild/openharmony-arm64": {
|
| 638 |
+
"version": "0.25.12",
|
| 639 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
| 640 |
+
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
| 641 |
+
"cpu": [
|
| 642 |
+
"arm64"
|
| 643 |
+
],
|
| 644 |
+
"dev": true,
|
| 645 |
+
"optional": true,
|
| 646 |
+
"os": [
|
| 647 |
+
"openharmony"
|
| 648 |
+
],
|
| 649 |
+
"engines": {
|
| 650 |
+
"node": ">=18"
|
| 651 |
+
}
|
| 652 |
+
},
|
| 653 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 654 |
+
"version": "0.25.12",
|
| 655 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
| 656 |
+
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
| 657 |
+
"cpu": [
|
| 658 |
+
"x64"
|
| 659 |
+
],
|
| 660 |
+
"dev": true,
|
| 661 |
+
"optional": true,
|
| 662 |
+
"os": [
|
| 663 |
+
"sunos"
|
| 664 |
+
],
|
| 665 |
+
"engines": {
|
| 666 |
+
"node": ">=18"
|
| 667 |
+
}
|
| 668 |
+
},
|
| 669 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 670 |
+
"version": "0.25.12",
|
| 671 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
| 672 |
+
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
| 673 |
+
"cpu": [
|
| 674 |
+
"arm64"
|
| 675 |
+
],
|
| 676 |
+
"dev": true,
|
| 677 |
+
"optional": true,
|
| 678 |
+
"os": [
|
| 679 |
+
"win32"
|
| 680 |
+
],
|
| 681 |
+
"engines": {
|
| 682 |
+
"node": ">=18"
|
| 683 |
+
}
|
| 684 |
+
},
|
| 685 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 686 |
+
"version": "0.25.12",
|
| 687 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
| 688 |
+
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
| 689 |
+
"cpu": [
|
| 690 |
+
"ia32"
|
| 691 |
+
],
|
| 692 |
+
"dev": true,
|
| 693 |
+
"optional": true,
|
| 694 |
+
"os": [
|
| 695 |
+
"win32"
|
| 696 |
+
],
|
| 697 |
+
"engines": {
|
| 698 |
+
"node": ">=18"
|
| 699 |
+
}
|
| 700 |
+
},
|
| 701 |
+
"node_modules/@esbuild/win32-x64": {
|
| 702 |
+
"version": "0.25.12",
|
| 703 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
| 704 |
+
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
| 705 |
+
"cpu": [
|
| 706 |
+
"x64"
|
| 707 |
+
],
|
| 708 |
+
"dev": true,
|
| 709 |
+
"optional": true,
|
| 710 |
+
"os": [
|
| 711 |
+
"win32"
|
| 712 |
+
],
|
| 713 |
+
"engines": {
|
| 714 |
+
"node": ">=18"
|
| 715 |
+
}
|
| 716 |
+
},
|
| 717 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 718 |
+
"version": "0.3.13",
|
| 719 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 720 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 721 |
+
"dev": true,
|
| 722 |
+
"dependencies": {
|
| 723 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 724 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 725 |
+
}
|
| 726 |
+
},
|
| 727 |
+
"node_modules/@jridgewell/remapping": {
|
| 728 |
+
"version": "2.3.5",
|
| 729 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 730 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 731 |
+
"dev": true,
|
| 732 |
+
"dependencies": {
|
| 733 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 734 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 735 |
+
}
|
| 736 |
+
},
|
| 737 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 738 |
+
"version": "3.1.2",
|
| 739 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 740 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 741 |
+
"dev": true,
|
| 742 |
+
"engines": {
|
| 743 |
+
"node": ">=6.0.0"
|
| 744 |
+
}
|
| 745 |
+
},
|
| 746 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 747 |
+
"version": "1.5.5",
|
| 748 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 749 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 750 |
+
"dev": true
|
| 751 |
+
},
|
| 752 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 753 |
+
"version": "0.3.31",
|
| 754 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 755 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 756 |
+
"dev": true,
|
| 757 |
+
"dependencies": {
|
| 758 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 759 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 760 |
+
}
|
| 761 |
+
},
|
| 762 |
+
"node_modules/@nodelib/fs.scandir": {
|
| 763 |
+
"version": "2.1.5",
|
| 764 |
+
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
| 765 |
+
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
| 766 |
+
"dev": true,
|
| 767 |
+
"dependencies": {
|
| 768 |
+
"@nodelib/fs.stat": "2.0.5",
|
| 769 |
+
"run-parallel": "^1.1.9"
|
| 770 |
+
},
|
| 771 |
+
"engines": {
|
| 772 |
+
"node": ">= 8"
|
| 773 |
+
}
|
| 774 |
+
},
|
| 775 |
+
"node_modules/@nodelib/fs.stat": {
|
| 776 |
+
"version": "2.0.5",
|
| 777 |
+
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
| 778 |
+
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
| 779 |
+
"dev": true,
|
| 780 |
+
"engines": {
|
| 781 |
+
"node": ">= 8"
|
| 782 |
+
}
|
| 783 |
+
},
|
| 784 |
+
"node_modules/@nodelib/fs.walk": {
|
| 785 |
+
"version": "1.2.8",
|
| 786 |
+
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
| 787 |
+
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
| 788 |
+
"dev": true,
|
| 789 |
+
"dependencies": {
|
| 790 |
+
"@nodelib/fs.scandir": "2.1.5",
|
| 791 |
+
"fastq": "^1.6.0"
|
| 792 |
+
},
|
| 793 |
+
"engines": {
|
| 794 |
+
"node": ">= 8"
|
| 795 |
+
}
|
| 796 |
+
},
|
| 797 |
+
"node_modules/@rolldown/pluginutils": {
|
| 798 |
+
"version": "1.0.0-beta.27",
|
| 799 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
| 800 |
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
| 801 |
+
"dev": true
|
| 802 |
+
},
|
| 803 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 804 |
+
"version": "4.60.2",
|
| 805 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
| 806 |
+
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
|
| 807 |
+
"cpu": [
|
| 808 |
+
"arm"
|
| 809 |
+
],
|
| 810 |
+
"dev": true,
|
| 811 |
+
"optional": true,
|
| 812 |
+
"os": [
|
| 813 |
+
"android"
|
| 814 |
+
]
|
| 815 |
+
},
|
| 816 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 817 |
+
"version": "4.60.2",
|
| 818 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
|
| 819 |
+
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
|
| 820 |
+
"cpu": [
|
| 821 |
+
"arm64"
|
| 822 |
+
],
|
| 823 |
+
"dev": true,
|
| 824 |
+
"optional": true,
|
| 825 |
+
"os": [
|
| 826 |
+
"android"
|
| 827 |
+
]
|
| 828 |
+
},
|
| 829 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 830 |
+
"version": "4.60.2",
|
| 831 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
| 832 |
+
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
| 833 |
+
"cpu": [
|
| 834 |
+
"arm64"
|
| 835 |
+
],
|
| 836 |
+
"dev": true,
|
| 837 |
+
"optional": true,
|
| 838 |
+
"os": [
|
| 839 |
+
"darwin"
|
| 840 |
+
]
|
| 841 |
+
},
|
| 842 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 843 |
+
"version": "4.60.2",
|
| 844 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
|
| 845 |
+
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
|
| 846 |
+
"cpu": [
|
| 847 |
+
"x64"
|
| 848 |
+
],
|
| 849 |
+
"dev": true,
|
| 850 |
+
"optional": true,
|
| 851 |
+
"os": [
|
| 852 |
+
"darwin"
|
| 853 |
+
]
|
| 854 |
+
},
|
| 855 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 856 |
+
"version": "4.60.2",
|
| 857 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
|
| 858 |
+
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
|
| 859 |
+
"cpu": [
|
| 860 |
+
"arm64"
|
| 861 |
+
],
|
| 862 |
+
"dev": true,
|
| 863 |
+
"optional": true,
|
| 864 |
+
"os": [
|
| 865 |
+
"freebsd"
|
| 866 |
+
]
|
| 867 |
+
},
|
| 868 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 869 |
+
"version": "4.60.2",
|
| 870 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
|
| 871 |
+
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
|
| 872 |
+
"cpu": [
|
| 873 |
+
"x64"
|
| 874 |
+
],
|
| 875 |
+
"dev": true,
|
| 876 |
+
"optional": true,
|
| 877 |
+
"os": [
|
| 878 |
+
"freebsd"
|
| 879 |
+
]
|
| 880 |
+
},
|
| 881 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 882 |
+
"version": "4.60.2",
|
| 883 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
|
| 884 |
+
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
|
| 885 |
+
"cpu": [
|
| 886 |
+
"arm"
|
| 887 |
+
],
|
| 888 |
+
"dev": true,
|
| 889 |
+
"optional": true,
|
| 890 |
+
"os": [
|
| 891 |
+
"linux"
|
| 892 |
+
]
|
| 893 |
+
},
|
| 894 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 895 |
+
"version": "4.60.2",
|
| 896 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
|
| 897 |
+
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
|
| 898 |
+
"cpu": [
|
| 899 |
+
"arm"
|
| 900 |
+
],
|
| 901 |
+
"dev": true,
|
| 902 |
+
"optional": true,
|
| 903 |
+
"os": [
|
| 904 |
+
"linux"
|
| 905 |
+
]
|
| 906 |
+
},
|
| 907 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 908 |
+
"version": "4.60.2",
|
| 909 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
|
| 910 |
+
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
|
| 911 |
+
"cpu": [
|
| 912 |
+
"arm64"
|
| 913 |
+
],
|
| 914 |
+
"dev": true,
|
| 915 |
+
"optional": true,
|
| 916 |
+
"os": [
|
| 917 |
+
"linux"
|
| 918 |
+
]
|
| 919 |
+
},
|
| 920 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 921 |
+
"version": "4.60.2",
|
| 922 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
|
| 923 |
+
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
|
| 924 |
+
"cpu": [
|
| 925 |
+
"arm64"
|
| 926 |
+
],
|
| 927 |
+
"dev": true,
|
| 928 |
+
"optional": true,
|
| 929 |
+
"os": [
|
| 930 |
+
"linux"
|
| 931 |
+
]
|
| 932 |
+
},
|
| 933 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 934 |
+
"version": "4.60.2",
|
| 935 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
|
| 936 |
+
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
|
| 937 |
+
"cpu": [
|
| 938 |
+
"loong64"
|
| 939 |
+
],
|
| 940 |
+
"dev": true,
|
| 941 |
+
"optional": true,
|
| 942 |
+
"os": [
|
| 943 |
+
"linux"
|
| 944 |
+
]
|
| 945 |
+
},
|
| 946 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 947 |
+
"version": "4.60.2",
|
| 948 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
|
| 949 |
+
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
|
| 950 |
+
"cpu": [
|
| 951 |
+
"loong64"
|
| 952 |
+
],
|
| 953 |
+
"dev": true,
|
| 954 |
+
"optional": true,
|
| 955 |
+
"os": [
|
| 956 |
+
"linux"
|
| 957 |
+
]
|
| 958 |
+
},
|
| 959 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 960 |
+
"version": "4.60.2",
|
| 961 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
|
| 962 |
+
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
|
| 963 |
+
"cpu": [
|
| 964 |
+
"ppc64"
|
| 965 |
+
],
|
| 966 |
+
"dev": true,
|
| 967 |
+
"optional": true,
|
| 968 |
+
"os": [
|
| 969 |
+
"linux"
|
| 970 |
+
]
|
| 971 |
+
},
|
| 972 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 973 |
+
"version": "4.60.2",
|
| 974 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
|
| 975 |
+
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
|
| 976 |
+
"cpu": [
|
| 977 |
+
"ppc64"
|
| 978 |
+
],
|
| 979 |
+
"dev": true,
|
| 980 |
+
"optional": true,
|
| 981 |
+
"os": [
|
| 982 |
+
"linux"
|
| 983 |
+
]
|
| 984 |
+
},
|
| 985 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 986 |
+
"version": "4.60.2",
|
| 987 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
|
| 988 |
+
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
|
| 989 |
+
"cpu": [
|
| 990 |
+
"riscv64"
|
| 991 |
+
],
|
| 992 |
+
"dev": true,
|
| 993 |
+
"optional": true,
|
| 994 |
+
"os": [
|
| 995 |
+
"linux"
|
| 996 |
+
]
|
| 997 |
+
},
|
| 998 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 999 |
+
"version": "4.60.2",
|
| 1000 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
|
| 1001 |
+
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
|
| 1002 |
+
"cpu": [
|
| 1003 |
+
"riscv64"
|
| 1004 |
+
],
|
| 1005 |
+
"dev": true,
|
| 1006 |
+
"optional": true,
|
| 1007 |
+
"os": [
|
| 1008 |
+
"linux"
|
| 1009 |
+
]
|
| 1010 |
+
},
|
| 1011 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 1012 |
+
"version": "4.60.2",
|
| 1013 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
| 1014 |
+
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
| 1015 |
+
"cpu": [
|
| 1016 |
+
"s390x"
|
| 1017 |
+
],
|
| 1018 |
+
"dev": true,
|
| 1019 |
+
"optional": true,
|
| 1020 |
+
"os": [
|
| 1021 |
+
"linux"
|
| 1022 |
+
]
|
| 1023 |
+
},
|
| 1024 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 1025 |
+
"version": "4.60.2",
|
| 1026 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
| 1027 |
+
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
| 1028 |
+
"cpu": [
|
| 1029 |
+
"x64"
|
| 1030 |
+
],
|
| 1031 |
+
"dev": true,
|
| 1032 |
+
"optional": true,
|
| 1033 |
+
"os": [
|
| 1034 |
+
"linux"
|
| 1035 |
+
]
|
| 1036 |
+
},
|
| 1037 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1038 |
+
"version": "4.60.2",
|
| 1039 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
| 1040 |
+
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
| 1041 |
+
"cpu": [
|
| 1042 |
+
"x64"
|
| 1043 |
+
],
|
| 1044 |
+
"dev": true,
|
| 1045 |
+
"optional": true,
|
| 1046 |
+
"os": [
|
| 1047 |
+
"linux"
|
| 1048 |
+
]
|
| 1049 |
+
},
|
| 1050 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1051 |
+
"version": "4.60.2",
|
| 1052 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
| 1053 |
+
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
| 1054 |
+
"cpu": [
|
| 1055 |
+
"x64"
|
| 1056 |
+
],
|
| 1057 |
+
"dev": true,
|
| 1058 |
+
"optional": true,
|
| 1059 |
+
"os": [
|
| 1060 |
+
"openbsd"
|
| 1061 |
+
]
|
| 1062 |
+
},
|
| 1063 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1064 |
+
"version": "4.60.2",
|
| 1065 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
| 1066 |
+
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
| 1067 |
+
"cpu": [
|
| 1068 |
+
"arm64"
|
| 1069 |
+
],
|
| 1070 |
+
"dev": true,
|
| 1071 |
+
"optional": true,
|
| 1072 |
+
"os": [
|
| 1073 |
+
"openharmony"
|
| 1074 |
+
]
|
| 1075 |
+
},
|
| 1076 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1077 |
+
"version": "4.60.2",
|
| 1078 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
| 1079 |
+
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
| 1080 |
+
"cpu": [
|
| 1081 |
+
"arm64"
|
| 1082 |
+
],
|
| 1083 |
+
"dev": true,
|
| 1084 |
+
"optional": true,
|
| 1085 |
+
"os": [
|
| 1086 |
+
"win32"
|
| 1087 |
+
]
|
| 1088 |
+
},
|
| 1089 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1090 |
+
"version": "4.60.2",
|
| 1091 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
| 1092 |
+
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
| 1093 |
+
"cpu": [
|
| 1094 |
+
"ia32"
|
| 1095 |
+
],
|
| 1096 |
+
"dev": true,
|
| 1097 |
+
"optional": true,
|
| 1098 |
+
"os": [
|
| 1099 |
+
"win32"
|
| 1100 |
+
]
|
| 1101 |
+
},
|
| 1102 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1103 |
+
"version": "4.60.2",
|
| 1104 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
|
| 1105 |
+
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
|
| 1106 |
+
"cpu": [
|
| 1107 |
+
"x64"
|
| 1108 |
+
],
|
| 1109 |
+
"dev": true,
|
| 1110 |
+
"optional": true,
|
| 1111 |
+
"os": [
|
| 1112 |
+
"win32"
|
| 1113 |
+
]
|
| 1114 |
+
},
|
| 1115 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1116 |
+
"version": "4.60.2",
|
| 1117 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
|
| 1118 |
+
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
|
| 1119 |
+
"cpu": [
|
| 1120 |
+
"x64"
|
| 1121 |
+
],
|
| 1122 |
+
"dev": true,
|
| 1123 |
+
"optional": true,
|
| 1124 |
+
"os": [
|
| 1125 |
+
"win32"
|
| 1126 |
+
]
|
| 1127 |
+
},
|
| 1128 |
+
"node_modules/@types/babel__core": {
|
| 1129 |
+
"version": "7.20.5",
|
| 1130 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1131 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1132 |
+
"dev": true,
|
| 1133 |
+
"dependencies": {
|
| 1134 |
+
"@babel/parser": "^7.20.7",
|
| 1135 |
+
"@babel/types": "^7.20.7",
|
| 1136 |
+
"@types/babel__generator": "*",
|
| 1137 |
+
"@types/babel__template": "*",
|
| 1138 |
+
"@types/babel__traverse": "*"
|
| 1139 |
+
}
|
| 1140 |
+
},
|
| 1141 |
+
"node_modules/@types/babel__generator": {
|
| 1142 |
+
"version": "7.27.0",
|
| 1143 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1144 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1145 |
+
"dev": true,
|
| 1146 |
+
"dependencies": {
|
| 1147 |
+
"@babel/types": "^7.0.0"
|
| 1148 |
+
}
|
| 1149 |
+
},
|
| 1150 |
+
"node_modules/@types/babel__template": {
|
| 1151 |
+
"version": "7.4.4",
|
| 1152 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1153 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1154 |
+
"dev": true,
|
| 1155 |
+
"dependencies": {
|
| 1156 |
+
"@babel/parser": "^7.1.0",
|
| 1157 |
+
"@babel/types": "^7.0.0"
|
| 1158 |
+
}
|
| 1159 |
+
},
|
| 1160 |
+
"node_modules/@types/babel__traverse": {
|
| 1161 |
+
"version": "7.28.0",
|
| 1162 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1163 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1164 |
+
"dev": true,
|
| 1165 |
+
"dependencies": {
|
| 1166 |
+
"@babel/types": "^7.28.2"
|
| 1167 |
+
}
|
| 1168 |
+
},
|
| 1169 |
+
"node_modules/@types/estree": {
|
| 1170 |
+
"version": "1.0.8",
|
| 1171 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
| 1172 |
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
| 1173 |
+
"dev": true
|
| 1174 |
+
},
|
| 1175 |
+
"node_modules/@types/react": {
|
| 1176 |
+
"version": "19.2.14",
|
| 1177 |
+
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
| 1178 |
+
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
| 1179 |
+
"dev": true,
|
| 1180 |
+
"dependencies": {
|
| 1181 |
+
"csstype": "^3.2.2"
|
| 1182 |
+
}
|
| 1183 |
+
},
|
| 1184 |
+
"node_modules/@types/react-dom": {
|
| 1185 |
+
"version": "19.2.3",
|
| 1186 |
+
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
| 1187 |
+
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
| 1188 |
+
"dev": true,
|
| 1189 |
+
"peerDependencies": {
|
| 1190 |
+
"@types/react": "^19.2.0"
|
| 1191 |
+
}
|
| 1192 |
+
},
|
| 1193 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1194 |
+
"version": "4.7.0",
|
| 1195 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
| 1196 |
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
| 1197 |
+
"dev": true,
|
| 1198 |
+
"dependencies": {
|
| 1199 |
+
"@babel/core": "^7.28.0",
|
| 1200 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1201 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1202 |
+
"@rolldown/pluginutils": "1.0.0-beta.27",
|
| 1203 |
+
"@types/babel__core": "^7.20.5",
|
| 1204 |
+
"react-refresh": "^0.17.0"
|
| 1205 |
+
},
|
| 1206 |
+
"engines": {
|
| 1207 |
+
"node": "^14.18.0 || >=16.0.0"
|
| 1208 |
+
},
|
| 1209 |
+
"peerDependencies": {
|
| 1210 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
| 1211 |
+
}
|
| 1212 |
+
},
|
| 1213 |
+
"node_modules/any-promise": {
|
| 1214 |
+
"version": "1.3.0",
|
| 1215 |
+
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
| 1216 |
+
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
| 1217 |
+
"dev": true
|
| 1218 |
+
},
|
| 1219 |
+
"node_modules/anymatch": {
|
| 1220 |
+
"version": "3.1.3",
|
| 1221 |
+
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
| 1222 |
+
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
| 1223 |
+
"dev": true,
|
| 1224 |
+
"dependencies": {
|
| 1225 |
+
"normalize-path": "^3.0.0",
|
| 1226 |
+
"picomatch": "^2.0.4"
|
| 1227 |
+
},
|
| 1228 |
+
"engines": {
|
| 1229 |
+
"node": ">= 8"
|
| 1230 |
+
}
|
| 1231 |
+
},
|
| 1232 |
+
"node_modules/arg": {
|
| 1233 |
+
"version": "5.0.2",
|
| 1234 |
+
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
| 1235 |
+
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
| 1236 |
+
"dev": true
|
| 1237 |
+
},
|
| 1238 |
+
"node_modules/autoprefixer": {
|
| 1239 |
+
"version": "10.5.0",
|
| 1240 |
+
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
| 1241 |
+
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
| 1242 |
+
"dev": true,
|
| 1243 |
+
"funding": [
|
| 1244 |
+
{
|
| 1245 |
+
"type": "opencollective",
|
| 1246 |
+
"url": "https://opencollective.com/postcss/"
|
| 1247 |
+
},
|
| 1248 |
+
{
|
| 1249 |
+
"type": "tidelift",
|
| 1250 |
+
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
| 1251 |
+
},
|
| 1252 |
+
{
|
| 1253 |
+
"type": "github",
|
| 1254 |
+
"url": "https://github.com/sponsors/ai"
|
| 1255 |
+
}
|
| 1256 |
+
],
|
| 1257 |
+
"dependencies": {
|
| 1258 |
+
"browserslist": "^4.28.2",
|
| 1259 |
+
"caniuse-lite": "^1.0.30001787",
|
| 1260 |
+
"fraction.js": "^5.3.4",
|
| 1261 |
+
"picocolors": "^1.1.1",
|
| 1262 |
+
"postcss-value-parser": "^4.2.0"
|
| 1263 |
+
},
|
| 1264 |
+
"bin": {
|
| 1265 |
+
"autoprefixer": "bin/autoprefixer"
|
| 1266 |
+
},
|
| 1267 |
+
"engines": {
|
| 1268 |
+
"node": "^10 || ^12 || >=14"
|
| 1269 |
+
},
|
| 1270 |
+
"peerDependencies": {
|
| 1271 |
+
"postcss": "^8.1.0"
|
| 1272 |
+
}
|
| 1273 |
+
},
|
| 1274 |
+
"node_modules/baseline-browser-mapping": {
|
| 1275 |
+
"version": "2.10.23",
|
| 1276 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz",
|
| 1277 |
+
"integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==",
|
| 1278 |
+
"dev": true,
|
| 1279 |
+
"bin": {
|
| 1280 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1281 |
+
},
|
| 1282 |
+
"engines": {
|
| 1283 |
+
"node": ">=6.0.0"
|
| 1284 |
+
}
|
| 1285 |
+
},
|
| 1286 |
+
"node_modules/binary-extensions": {
|
| 1287 |
+
"version": "2.3.0",
|
| 1288 |
+
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
| 1289 |
+
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
| 1290 |
+
"dev": true,
|
| 1291 |
+
"engines": {
|
| 1292 |
+
"node": ">=8"
|
| 1293 |
+
},
|
| 1294 |
+
"funding": {
|
| 1295 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 1296 |
+
}
|
| 1297 |
+
},
|
| 1298 |
+
"node_modules/braces": {
|
| 1299 |
+
"version": "3.0.3",
|
| 1300 |
+
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
| 1301 |
+
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
| 1302 |
+
"dev": true,
|
| 1303 |
+
"dependencies": {
|
| 1304 |
+
"fill-range": "^7.1.1"
|
| 1305 |
+
},
|
| 1306 |
+
"engines": {
|
| 1307 |
+
"node": ">=8"
|
| 1308 |
+
}
|
| 1309 |
+
},
|
| 1310 |
+
"node_modules/browserslist": {
|
| 1311 |
+
"version": "4.28.2",
|
| 1312 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
| 1313 |
+
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
| 1314 |
+
"dev": true,
|
| 1315 |
+
"funding": [
|
| 1316 |
+
{
|
| 1317 |
+
"type": "opencollective",
|
| 1318 |
+
"url": "https://opencollective.com/browserslist"
|
| 1319 |
+
},
|
| 1320 |
+
{
|
| 1321 |
+
"type": "tidelift",
|
| 1322 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1323 |
+
},
|
| 1324 |
+
{
|
| 1325 |
+
"type": "github",
|
| 1326 |
+
"url": "https://github.com/sponsors/ai"
|
| 1327 |
+
}
|
| 1328 |
+
],
|
| 1329 |
+
"dependencies": {
|
| 1330 |
+
"baseline-browser-mapping": "^2.10.12",
|
| 1331 |
+
"caniuse-lite": "^1.0.30001782",
|
| 1332 |
+
"electron-to-chromium": "^1.5.328",
|
| 1333 |
+
"node-releases": "^2.0.36",
|
| 1334 |
+
"update-browserslist-db": "^1.2.3"
|
| 1335 |
+
},
|
| 1336 |
+
"bin": {
|
| 1337 |
+
"browserslist": "cli.js"
|
| 1338 |
+
},
|
| 1339 |
+
"engines": {
|
| 1340 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1341 |
+
}
|
| 1342 |
+
},
|
| 1343 |
+
"node_modules/camelcase-css": {
|
| 1344 |
+
"version": "2.0.1",
|
| 1345 |
+
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
| 1346 |
+
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
| 1347 |
+
"dev": true,
|
| 1348 |
+
"engines": {
|
| 1349 |
+
"node": ">= 6"
|
| 1350 |
+
}
|
| 1351 |
+
},
|
| 1352 |
+
"node_modules/caniuse-lite": {
|
| 1353 |
+
"version": "1.0.30001791",
|
| 1354 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
| 1355 |
+
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
| 1356 |
+
"dev": true,
|
| 1357 |
+
"funding": [
|
| 1358 |
+
{
|
| 1359 |
+
"type": "opencollective",
|
| 1360 |
+
"url": "https://opencollective.com/browserslist"
|
| 1361 |
+
},
|
| 1362 |
+
{
|
| 1363 |
+
"type": "tidelift",
|
| 1364 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1365 |
+
},
|
| 1366 |
+
{
|
| 1367 |
+
"type": "github",
|
| 1368 |
+
"url": "https://github.com/sponsors/ai"
|
| 1369 |
+
}
|
| 1370 |
+
]
|
| 1371 |
+
},
|
| 1372 |
+
"node_modules/chokidar": {
|
| 1373 |
+
"version": "3.6.0",
|
| 1374 |
+
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
| 1375 |
+
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
| 1376 |
+
"dev": true,
|
| 1377 |
+
"dependencies": {
|
| 1378 |
+
"anymatch": "~3.1.2",
|
| 1379 |
+
"braces": "~3.0.2",
|
| 1380 |
+
"glob-parent": "~5.1.2",
|
| 1381 |
+
"is-binary-path": "~2.1.0",
|
| 1382 |
+
"is-glob": "~4.0.1",
|
| 1383 |
+
"normalize-path": "~3.0.0",
|
| 1384 |
+
"readdirp": "~3.6.0"
|
| 1385 |
+
},
|
| 1386 |
+
"engines": {
|
| 1387 |
+
"node": ">= 8.10.0"
|
| 1388 |
+
},
|
| 1389 |
+
"funding": {
|
| 1390 |
+
"url": "https://paulmillr.com/funding/"
|
| 1391 |
+
},
|
| 1392 |
+
"optionalDependencies": {
|
| 1393 |
+
"fsevents": "~2.3.2"
|
| 1394 |
+
}
|
| 1395 |
+
},
|
| 1396 |
+
"node_modules/chokidar/node_modules/glob-parent": {
|
| 1397 |
+
"version": "5.1.2",
|
| 1398 |
+
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
| 1399 |
+
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
| 1400 |
+
"dev": true,
|
| 1401 |
+
"dependencies": {
|
| 1402 |
+
"is-glob": "^4.0.1"
|
| 1403 |
+
},
|
| 1404 |
+
"engines": {
|
| 1405 |
+
"node": ">= 6"
|
| 1406 |
+
}
|
| 1407 |
+
},
|
| 1408 |
+
"node_modules/commander": {
|
| 1409 |
+
"version": "4.1.1",
|
| 1410 |
+
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
| 1411 |
+
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
| 1412 |
+
"dev": true,
|
| 1413 |
+
"engines": {
|
| 1414 |
+
"node": ">= 6"
|
| 1415 |
+
}
|
| 1416 |
+
},
|
| 1417 |
+
"node_modules/convert-source-map": {
|
| 1418 |
+
"version": "2.0.0",
|
| 1419 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1420 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1421 |
+
"dev": true
|
| 1422 |
+
},
|
| 1423 |
+
"node_modules/cssesc": {
|
| 1424 |
+
"version": "3.0.0",
|
| 1425 |
+
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
| 1426 |
+
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
| 1427 |
+
"dev": true,
|
| 1428 |
+
"bin": {
|
| 1429 |
+
"cssesc": "bin/cssesc"
|
| 1430 |
+
},
|
| 1431 |
+
"engines": {
|
| 1432 |
+
"node": ">=4"
|
| 1433 |
+
}
|
| 1434 |
+
},
|
| 1435 |
+
"node_modules/csstype": {
|
| 1436 |
+
"version": "3.2.3",
|
| 1437 |
+
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 1438 |
+
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 1439 |
+
"dev": true
|
| 1440 |
+
},
|
| 1441 |
+
"node_modules/debug": {
|
| 1442 |
+
"version": "4.4.3",
|
| 1443 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1444 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1445 |
+
"dev": true,
|
| 1446 |
+
"dependencies": {
|
| 1447 |
+
"ms": "^2.1.3"
|
| 1448 |
+
},
|
| 1449 |
+
"engines": {
|
| 1450 |
+
"node": ">=6.0"
|
| 1451 |
+
},
|
| 1452 |
+
"peerDependenciesMeta": {
|
| 1453 |
+
"supports-color": {
|
| 1454 |
+
"optional": true
|
| 1455 |
+
}
|
| 1456 |
+
}
|
| 1457 |
+
},
|
| 1458 |
+
"node_modules/didyoumean": {
|
| 1459 |
+
"version": "1.2.2",
|
| 1460 |
+
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
| 1461 |
+
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
| 1462 |
+
"dev": true
|
| 1463 |
+
},
|
| 1464 |
+
"node_modules/dlv": {
|
| 1465 |
+
"version": "1.1.3",
|
| 1466 |
+
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
| 1467 |
+
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
| 1468 |
+
"dev": true
|
| 1469 |
+
},
|
| 1470 |
+
"node_modules/electron-to-chromium": {
|
| 1471 |
+
"version": "1.5.344",
|
| 1472 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
|
| 1473 |
+
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
|
| 1474 |
+
"dev": true
|
| 1475 |
+
},
|
| 1476 |
+
"node_modules/es-errors": {
|
| 1477 |
+
"version": "1.3.0",
|
| 1478 |
+
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
| 1479 |
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
| 1480 |
+
"dev": true,
|
| 1481 |
+
"engines": {
|
| 1482 |
+
"node": ">= 0.4"
|
| 1483 |
+
}
|
| 1484 |
+
},
|
| 1485 |
+
"node_modules/esbuild": {
|
| 1486 |
+
"version": "0.25.12",
|
| 1487 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
| 1488 |
+
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
| 1489 |
+
"dev": true,
|
| 1490 |
+
"hasInstallScript": true,
|
| 1491 |
+
"bin": {
|
| 1492 |
+
"esbuild": "bin/esbuild"
|
| 1493 |
+
},
|
| 1494 |
+
"engines": {
|
| 1495 |
+
"node": ">=18"
|
| 1496 |
+
},
|
| 1497 |
+
"optionalDependencies": {
|
| 1498 |
+
"@esbuild/aix-ppc64": "0.25.12",
|
| 1499 |
+
"@esbuild/android-arm": "0.25.12",
|
| 1500 |
+
"@esbuild/android-arm64": "0.25.12",
|
| 1501 |
+
"@esbuild/android-x64": "0.25.12",
|
| 1502 |
+
"@esbuild/darwin-arm64": "0.25.12",
|
| 1503 |
+
"@esbuild/darwin-x64": "0.25.12",
|
| 1504 |
+
"@esbuild/freebsd-arm64": "0.25.12",
|
| 1505 |
+
"@esbuild/freebsd-x64": "0.25.12",
|
| 1506 |
+
"@esbuild/linux-arm": "0.25.12",
|
| 1507 |
+
"@esbuild/linux-arm64": "0.25.12",
|
| 1508 |
+
"@esbuild/linux-ia32": "0.25.12",
|
| 1509 |
+
"@esbuild/linux-loong64": "0.25.12",
|
| 1510 |
+
"@esbuild/linux-mips64el": "0.25.12",
|
| 1511 |
+
"@esbuild/linux-ppc64": "0.25.12",
|
| 1512 |
+
"@esbuild/linux-riscv64": "0.25.12",
|
| 1513 |
+
"@esbuild/linux-s390x": "0.25.12",
|
| 1514 |
+
"@esbuild/linux-x64": "0.25.12",
|
| 1515 |
+
"@esbuild/netbsd-arm64": "0.25.12",
|
| 1516 |
+
"@esbuild/netbsd-x64": "0.25.12",
|
| 1517 |
+
"@esbuild/openbsd-arm64": "0.25.12",
|
| 1518 |
+
"@esbuild/openbsd-x64": "0.25.12",
|
| 1519 |
+
"@esbuild/openharmony-arm64": "0.25.12",
|
| 1520 |
+
"@esbuild/sunos-x64": "0.25.12",
|
| 1521 |
+
"@esbuild/win32-arm64": "0.25.12",
|
| 1522 |
+
"@esbuild/win32-ia32": "0.25.12",
|
| 1523 |
+
"@esbuild/win32-x64": "0.25.12"
|
| 1524 |
+
}
|
| 1525 |
+
},
|
| 1526 |
+
"node_modules/escalade": {
|
| 1527 |
+
"version": "3.2.0",
|
| 1528 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1529 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1530 |
+
"dev": true,
|
| 1531 |
+
"engines": {
|
| 1532 |
+
"node": ">=6"
|
| 1533 |
+
}
|
| 1534 |
+
},
|
| 1535 |
+
"node_modules/fast-glob": {
|
| 1536 |
+
"version": "3.3.3",
|
| 1537 |
+
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
| 1538 |
+
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
| 1539 |
+
"dev": true,
|
| 1540 |
+
"dependencies": {
|
| 1541 |
+
"@nodelib/fs.stat": "^2.0.2",
|
| 1542 |
+
"@nodelib/fs.walk": "^1.2.3",
|
| 1543 |
+
"glob-parent": "^5.1.2",
|
| 1544 |
+
"merge2": "^1.3.0",
|
| 1545 |
+
"micromatch": "^4.0.8"
|
| 1546 |
+
},
|
| 1547 |
+
"engines": {
|
| 1548 |
+
"node": ">=8.6.0"
|
| 1549 |
+
}
|
| 1550 |
+
},
|
| 1551 |
+
"node_modules/fast-glob/node_modules/glob-parent": {
|
| 1552 |
+
"version": "5.1.2",
|
| 1553 |
+
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
| 1554 |
+
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
| 1555 |
+
"dev": true,
|
| 1556 |
+
"dependencies": {
|
| 1557 |
+
"is-glob": "^4.0.1"
|
| 1558 |
+
},
|
| 1559 |
+
"engines": {
|
| 1560 |
+
"node": ">= 6"
|
| 1561 |
+
}
|
| 1562 |
+
},
|
| 1563 |
+
"node_modules/fastq": {
|
| 1564 |
+
"version": "1.20.1",
|
| 1565 |
+
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
| 1566 |
+
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
| 1567 |
+
"dev": true,
|
| 1568 |
+
"dependencies": {
|
| 1569 |
+
"reusify": "^1.0.4"
|
| 1570 |
+
}
|
| 1571 |
+
},
|
| 1572 |
+
"node_modules/fill-range": {
|
| 1573 |
+
"version": "7.1.1",
|
| 1574 |
+
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
| 1575 |
+
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
| 1576 |
+
"dev": true,
|
| 1577 |
+
"dependencies": {
|
| 1578 |
+
"to-regex-range": "^5.0.1"
|
| 1579 |
+
},
|
| 1580 |
+
"engines": {
|
| 1581 |
+
"node": ">=8"
|
| 1582 |
+
}
|
| 1583 |
+
},
|
| 1584 |
+
"node_modules/fraction.js": {
|
| 1585 |
+
"version": "5.3.4",
|
| 1586 |
+
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
| 1587 |
+
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
| 1588 |
+
"dev": true,
|
| 1589 |
+
"engines": {
|
| 1590 |
+
"node": "*"
|
| 1591 |
+
},
|
| 1592 |
+
"funding": {
|
| 1593 |
+
"type": "github",
|
| 1594 |
+
"url": "https://github.com/sponsors/rawify"
|
| 1595 |
+
}
|
| 1596 |
+
},
|
| 1597 |
+
"node_modules/framer-motion": {
|
| 1598 |
+
"version": "11.18.2",
|
| 1599 |
+
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
| 1600 |
+
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
|
| 1601 |
+
"dependencies": {
|
| 1602 |
+
"motion-dom": "^11.18.1",
|
| 1603 |
+
"motion-utils": "^11.18.1",
|
| 1604 |
+
"tslib": "^2.4.0"
|
| 1605 |
+
},
|
| 1606 |
+
"peerDependencies": {
|
| 1607 |
+
"@emotion/is-prop-valid": "*",
|
| 1608 |
+
"react": "^18.0.0 || ^19.0.0",
|
| 1609 |
+
"react-dom": "^18.0.0 || ^19.0.0"
|
| 1610 |
+
},
|
| 1611 |
+
"peerDependenciesMeta": {
|
| 1612 |
+
"@emotion/is-prop-valid": {
|
| 1613 |
+
"optional": true
|
| 1614 |
+
},
|
| 1615 |
+
"react": {
|
| 1616 |
+
"optional": true
|
| 1617 |
+
},
|
| 1618 |
+
"react-dom": {
|
| 1619 |
+
"optional": true
|
| 1620 |
+
}
|
| 1621 |
+
}
|
| 1622 |
+
},
|
| 1623 |
+
"node_modules/fsevents": {
|
| 1624 |
+
"version": "2.3.3",
|
| 1625 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1626 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1627 |
+
"dev": true,
|
| 1628 |
+
"hasInstallScript": true,
|
| 1629 |
+
"optional": true,
|
| 1630 |
+
"os": [
|
| 1631 |
+
"darwin"
|
| 1632 |
+
],
|
| 1633 |
+
"engines": {
|
| 1634 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1635 |
+
}
|
| 1636 |
+
},
|
| 1637 |
+
"node_modules/function-bind": {
|
| 1638 |
+
"version": "1.1.2",
|
| 1639 |
+
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
| 1640 |
+
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
| 1641 |
+
"dev": true,
|
| 1642 |
+
"funding": {
|
| 1643 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1644 |
+
}
|
| 1645 |
+
},
|
| 1646 |
+
"node_modules/gensync": {
|
| 1647 |
+
"version": "1.0.0-beta.2",
|
| 1648 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1649 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1650 |
+
"dev": true,
|
| 1651 |
+
"engines": {
|
| 1652 |
+
"node": ">=6.9.0"
|
| 1653 |
+
}
|
| 1654 |
+
},
|
| 1655 |
+
"node_modules/glob-parent": {
|
| 1656 |
+
"version": "6.0.2",
|
| 1657 |
+
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
| 1658 |
+
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
| 1659 |
+
"dev": true,
|
| 1660 |
+
"dependencies": {
|
| 1661 |
+
"is-glob": "^4.0.3"
|
| 1662 |
+
},
|
| 1663 |
+
"engines": {
|
| 1664 |
+
"node": ">=10.13.0"
|
| 1665 |
+
}
|
| 1666 |
+
},
|
| 1667 |
+
"node_modules/hasown": {
|
| 1668 |
+
"version": "2.0.3",
|
| 1669 |
+
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
| 1670 |
+
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
| 1671 |
+
"dev": true,
|
| 1672 |
+
"dependencies": {
|
| 1673 |
+
"function-bind": "^1.1.2"
|
| 1674 |
+
},
|
| 1675 |
+
"engines": {
|
| 1676 |
+
"node": ">= 0.4"
|
| 1677 |
+
}
|
| 1678 |
+
},
|
| 1679 |
+
"node_modules/is-binary-path": {
|
| 1680 |
+
"version": "2.1.0",
|
| 1681 |
+
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
| 1682 |
+
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
| 1683 |
+
"dev": true,
|
| 1684 |
+
"dependencies": {
|
| 1685 |
+
"binary-extensions": "^2.0.0"
|
| 1686 |
+
},
|
| 1687 |
+
"engines": {
|
| 1688 |
+
"node": ">=8"
|
| 1689 |
+
}
|
| 1690 |
+
},
|
| 1691 |
+
"node_modules/is-core-module": {
|
| 1692 |
+
"version": "2.16.1",
|
| 1693 |
+
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
| 1694 |
+
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
| 1695 |
+
"dev": true,
|
| 1696 |
+
"dependencies": {
|
| 1697 |
+
"hasown": "^2.0.2"
|
| 1698 |
+
},
|
| 1699 |
+
"engines": {
|
| 1700 |
+
"node": ">= 0.4"
|
| 1701 |
+
},
|
| 1702 |
+
"funding": {
|
| 1703 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1704 |
+
}
|
| 1705 |
+
},
|
| 1706 |
+
"node_modules/is-extglob": {
|
| 1707 |
+
"version": "2.1.1",
|
| 1708 |
+
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
| 1709 |
+
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
| 1710 |
+
"dev": true,
|
| 1711 |
+
"engines": {
|
| 1712 |
+
"node": ">=0.10.0"
|
| 1713 |
+
}
|
| 1714 |
+
},
|
| 1715 |
+
"node_modules/is-glob": {
|
| 1716 |
+
"version": "4.0.3",
|
| 1717 |
+
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
| 1718 |
+
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
| 1719 |
+
"dev": true,
|
| 1720 |
+
"dependencies": {
|
| 1721 |
+
"is-extglob": "^2.1.1"
|
| 1722 |
+
},
|
| 1723 |
+
"engines": {
|
| 1724 |
+
"node": ">=0.10.0"
|
| 1725 |
+
}
|
| 1726 |
+
},
|
| 1727 |
+
"node_modules/is-number": {
|
| 1728 |
+
"version": "7.0.0",
|
| 1729 |
+
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
| 1730 |
+
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
| 1731 |
+
"dev": true,
|
| 1732 |
+
"engines": {
|
| 1733 |
+
"node": ">=0.12.0"
|
| 1734 |
+
}
|
| 1735 |
+
},
|
| 1736 |
+
"node_modules/jiti": {
|
| 1737 |
+
"version": "1.21.7",
|
| 1738 |
+
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
| 1739 |
+
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
| 1740 |
+
"dev": true,
|
| 1741 |
+
"bin": {
|
| 1742 |
+
"jiti": "bin/jiti.js"
|
| 1743 |
+
}
|
| 1744 |
+
},
|
| 1745 |
+
"node_modules/js-tokens": {
|
| 1746 |
+
"version": "4.0.0",
|
| 1747 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1748 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1749 |
+
"dev": true
|
| 1750 |
+
},
|
| 1751 |
+
"node_modules/jsesc": {
|
| 1752 |
+
"version": "3.1.0",
|
| 1753 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1754 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1755 |
+
"dev": true,
|
| 1756 |
+
"bin": {
|
| 1757 |
+
"jsesc": "bin/jsesc"
|
| 1758 |
+
},
|
| 1759 |
+
"engines": {
|
| 1760 |
+
"node": ">=6"
|
| 1761 |
+
}
|
| 1762 |
+
},
|
| 1763 |
+
"node_modules/json5": {
|
| 1764 |
+
"version": "2.2.3",
|
| 1765 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1766 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1767 |
+
"dev": true,
|
| 1768 |
+
"bin": {
|
| 1769 |
+
"json5": "lib/cli.js"
|
| 1770 |
+
},
|
| 1771 |
+
"engines": {
|
| 1772 |
+
"node": ">=6"
|
| 1773 |
+
}
|
| 1774 |
+
},
|
| 1775 |
+
"node_modules/lilconfig": {
|
| 1776 |
+
"version": "3.1.3",
|
| 1777 |
+
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
| 1778 |
+
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
| 1779 |
+
"dev": true,
|
| 1780 |
+
"engines": {
|
| 1781 |
+
"node": ">=14"
|
| 1782 |
+
},
|
| 1783 |
+
"funding": {
|
| 1784 |
+
"url": "https://github.com/sponsors/antonk52"
|
| 1785 |
+
}
|
| 1786 |
+
},
|
| 1787 |
+
"node_modules/lines-and-columns": {
|
| 1788 |
+
"version": "1.2.4",
|
| 1789 |
+
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
| 1790 |
+
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
| 1791 |
+
"dev": true
|
| 1792 |
+
},
|
| 1793 |
+
"node_modules/lru-cache": {
|
| 1794 |
+
"version": "5.1.1",
|
| 1795 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1796 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1797 |
+
"dev": true,
|
| 1798 |
+
"dependencies": {
|
| 1799 |
+
"yallist": "^3.0.2"
|
| 1800 |
+
}
|
| 1801 |
+
},
|
| 1802 |
+
"node_modules/merge2": {
|
| 1803 |
+
"version": "1.4.1",
|
| 1804 |
+
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
| 1805 |
+
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
| 1806 |
+
"dev": true,
|
| 1807 |
+
"engines": {
|
| 1808 |
+
"node": ">= 8"
|
| 1809 |
+
}
|
| 1810 |
+
},
|
| 1811 |
+
"node_modules/micromatch": {
|
| 1812 |
+
"version": "4.0.8",
|
| 1813 |
+
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
| 1814 |
+
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
| 1815 |
+
"dev": true,
|
| 1816 |
+
"dependencies": {
|
| 1817 |
+
"braces": "^3.0.3",
|
| 1818 |
+
"picomatch": "^2.3.1"
|
| 1819 |
+
},
|
| 1820 |
+
"engines": {
|
| 1821 |
+
"node": ">=8.6"
|
| 1822 |
+
}
|
| 1823 |
+
},
|
| 1824 |
+
"node_modules/motion-dom": {
|
| 1825 |
+
"version": "11.18.1",
|
| 1826 |
+
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
| 1827 |
+
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
|
| 1828 |
+
"dependencies": {
|
| 1829 |
+
"motion-utils": "^11.18.1"
|
| 1830 |
+
}
|
| 1831 |
+
},
|
| 1832 |
+
"node_modules/motion-utils": {
|
| 1833 |
+
"version": "11.18.1",
|
| 1834 |
+
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
|
| 1835 |
+
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="
|
| 1836 |
+
},
|
| 1837 |
+
"node_modules/ms": {
|
| 1838 |
+
"version": "2.1.3",
|
| 1839 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1840 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1841 |
+
"dev": true
|
| 1842 |
+
},
|
| 1843 |
+
"node_modules/mz": {
|
| 1844 |
+
"version": "2.7.0",
|
| 1845 |
+
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
| 1846 |
+
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
| 1847 |
+
"dev": true,
|
| 1848 |
+
"dependencies": {
|
| 1849 |
+
"any-promise": "^1.0.0",
|
| 1850 |
+
"object-assign": "^4.0.1",
|
| 1851 |
+
"thenify-all": "^1.0.0"
|
| 1852 |
+
}
|
| 1853 |
+
},
|
| 1854 |
+
"node_modules/nanoid": {
|
| 1855 |
+
"version": "3.3.11",
|
| 1856 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
| 1857 |
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
| 1858 |
+
"dev": true,
|
| 1859 |
+
"funding": [
|
| 1860 |
+
{
|
| 1861 |
+
"type": "github",
|
| 1862 |
+
"url": "https://github.com/sponsors/ai"
|
| 1863 |
+
}
|
| 1864 |
+
],
|
| 1865 |
+
"bin": {
|
| 1866 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1867 |
+
},
|
| 1868 |
+
"engines": {
|
| 1869 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1870 |
+
}
|
| 1871 |
+
},
|
| 1872 |
+
"node_modules/node-releases": {
|
| 1873 |
+
"version": "2.0.38",
|
| 1874 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
| 1875 |
+
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
| 1876 |
+
"dev": true
|
| 1877 |
+
},
|
| 1878 |
+
"node_modules/normalize-path": {
|
| 1879 |
+
"version": "3.0.0",
|
| 1880 |
+
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
| 1881 |
+
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
| 1882 |
+
"dev": true,
|
| 1883 |
+
"engines": {
|
| 1884 |
+
"node": ">=0.10.0"
|
| 1885 |
+
}
|
| 1886 |
+
},
|
| 1887 |
+
"node_modules/object-assign": {
|
| 1888 |
+
"version": "4.1.1",
|
| 1889 |
+
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
| 1890 |
+
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
| 1891 |
+
"dev": true,
|
| 1892 |
+
"engines": {
|
| 1893 |
+
"node": ">=0.10.0"
|
| 1894 |
+
}
|
| 1895 |
+
},
|
| 1896 |
+
"node_modules/object-hash": {
|
| 1897 |
+
"version": "3.0.0",
|
| 1898 |
+
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
| 1899 |
+
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
| 1900 |
+
"dev": true,
|
| 1901 |
+
"engines": {
|
| 1902 |
+
"node": ">= 6"
|
| 1903 |
+
}
|
| 1904 |
+
},
|
| 1905 |
+
"node_modules/path-parse": {
|
| 1906 |
+
"version": "1.0.7",
|
| 1907 |
+
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
| 1908 |
+
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
| 1909 |
+
"dev": true
|
| 1910 |
+
},
|
| 1911 |
+
"node_modules/picocolors": {
|
| 1912 |
+
"version": "1.1.1",
|
| 1913 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1914 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1915 |
+
"dev": true
|
| 1916 |
+
},
|
| 1917 |
+
"node_modules/picomatch": {
|
| 1918 |
+
"version": "2.3.2",
|
| 1919 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
| 1920 |
+
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
| 1921 |
+
"dev": true,
|
| 1922 |
+
"engines": {
|
| 1923 |
+
"node": ">=8.6"
|
| 1924 |
+
},
|
| 1925 |
+
"funding": {
|
| 1926 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 1927 |
+
}
|
| 1928 |
+
},
|
| 1929 |
+
"node_modules/pify": {
|
| 1930 |
+
"version": "2.3.0",
|
| 1931 |
+
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
| 1932 |
+
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
| 1933 |
+
"dev": true,
|
| 1934 |
+
"engines": {
|
| 1935 |
+
"node": ">=0.10.0"
|
| 1936 |
+
}
|
| 1937 |
+
},
|
| 1938 |
+
"node_modules/pirates": {
|
| 1939 |
+
"version": "4.0.7",
|
| 1940 |
+
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
| 1941 |
+
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
| 1942 |
+
"dev": true,
|
| 1943 |
+
"engines": {
|
| 1944 |
+
"node": ">= 6"
|
| 1945 |
+
}
|
| 1946 |
+
},
|
| 1947 |
+
"node_modules/postcss": {
|
| 1948 |
+
"version": "8.5.12",
|
| 1949 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
| 1950 |
+
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
| 1951 |
+
"dev": true,
|
| 1952 |
+
"funding": [
|
| 1953 |
+
{
|
| 1954 |
+
"type": "opencollective",
|
| 1955 |
+
"url": "https://opencollective.com/postcss/"
|
| 1956 |
+
},
|
| 1957 |
+
{
|
| 1958 |
+
"type": "tidelift",
|
| 1959 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1960 |
+
},
|
| 1961 |
+
{
|
| 1962 |
+
"type": "github",
|
| 1963 |
+
"url": "https://github.com/sponsors/ai"
|
| 1964 |
+
}
|
| 1965 |
+
],
|
| 1966 |
+
"dependencies": {
|
| 1967 |
+
"nanoid": "^3.3.11",
|
| 1968 |
+
"picocolors": "^1.1.1",
|
| 1969 |
+
"source-map-js": "^1.2.1"
|
| 1970 |
+
},
|
| 1971 |
+
"engines": {
|
| 1972 |
+
"node": "^10 || ^12 || >=14"
|
| 1973 |
+
}
|
| 1974 |
+
},
|
| 1975 |
+
"node_modules/postcss-import": {
|
| 1976 |
+
"version": "15.1.0",
|
| 1977 |
+
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
| 1978 |
+
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
| 1979 |
+
"dev": true,
|
| 1980 |
+
"dependencies": {
|
| 1981 |
+
"postcss-value-parser": "^4.0.0",
|
| 1982 |
+
"read-cache": "^1.0.0",
|
| 1983 |
+
"resolve": "^1.1.7"
|
| 1984 |
+
},
|
| 1985 |
+
"engines": {
|
| 1986 |
+
"node": ">=14.0.0"
|
| 1987 |
+
},
|
| 1988 |
+
"peerDependencies": {
|
| 1989 |
+
"postcss": "^8.0.0"
|
| 1990 |
+
}
|
| 1991 |
+
},
|
| 1992 |
+
"node_modules/postcss-js": {
|
| 1993 |
+
"version": "4.1.0",
|
| 1994 |
+
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
| 1995 |
+
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
| 1996 |
+
"dev": true,
|
| 1997 |
+
"funding": [
|
| 1998 |
+
{
|
| 1999 |
+
"type": "opencollective",
|
| 2000 |
+
"url": "https://opencollective.com/postcss/"
|
| 2001 |
+
},
|
| 2002 |
+
{
|
| 2003 |
+
"type": "github",
|
| 2004 |
+
"url": "https://github.com/sponsors/ai"
|
| 2005 |
+
}
|
| 2006 |
+
],
|
| 2007 |
+
"dependencies": {
|
| 2008 |
+
"camelcase-css": "^2.0.1"
|
| 2009 |
+
},
|
| 2010 |
+
"engines": {
|
| 2011 |
+
"node": "^12 || ^14 || >= 16"
|
| 2012 |
+
},
|
| 2013 |
+
"peerDependencies": {
|
| 2014 |
+
"postcss": "^8.4.21"
|
| 2015 |
+
}
|
| 2016 |
+
},
|
| 2017 |
+
"node_modules/postcss-load-config": {
|
| 2018 |
+
"version": "6.0.1",
|
| 2019 |
+
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
| 2020 |
+
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
| 2021 |
+
"dev": true,
|
| 2022 |
+
"funding": [
|
| 2023 |
+
{
|
| 2024 |
+
"type": "opencollective",
|
| 2025 |
+
"url": "https://opencollective.com/postcss/"
|
| 2026 |
+
},
|
| 2027 |
+
{
|
| 2028 |
+
"type": "github",
|
| 2029 |
+
"url": "https://github.com/sponsors/ai"
|
| 2030 |
+
}
|
| 2031 |
+
],
|
| 2032 |
+
"dependencies": {
|
| 2033 |
+
"lilconfig": "^3.1.1"
|
| 2034 |
+
},
|
| 2035 |
+
"engines": {
|
| 2036 |
+
"node": ">= 18"
|
| 2037 |
+
},
|
| 2038 |
+
"peerDependencies": {
|
| 2039 |
+
"jiti": ">=1.21.0",
|
| 2040 |
+
"postcss": ">=8.0.9",
|
| 2041 |
+
"tsx": "^4.8.1",
|
| 2042 |
+
"yaml": "^2.4.2"
|
| 2043 |
+
},
|
| 2044 |
+
"peerDependenciesMeta": {
|
| 2045 |
+
"jiti": {
|
| 2046 |
+
"optional": true
|
| 2047 |
+
},
|
| 2048 |
+
"postcss": {
|
| 2049 |
+
"optional": true
|
| 2050 |
+
},
|
| 2051 |
+
"tsx": {
|
| 2052 |
+
"optional": true
|
| 2053 |
+
},
|
| 2054 |
+
"yaml": {
|
| 2055 |
+
"optional": true
|
| 2056 |
+
}
|
| 2057 |
+
}
|
| 2058 |
+
},
|
| 2059 |
+
"node_modules/postcss-nested": {
|
| 2060 |
+
"version": "6.2.0",
|
| 2061 |
+
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
| 2062 |
+
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
| 2063 |
+
"dev": true,
|
| 2064 |
+
"funding": [
|
| 2065 |
+
{
|
| 2066 |
+
"type": "opencollective",
|
| 2067 |
+
"url": "https://opencollective.com/postcss/"
|
| 2068 |
+
},
|
| 2069 |
+
{
|
| 2070 |
+
"type": "github",
|
| 2071 |
+
"url": "https://github.com/sponsors/ai"
|
| 2072 |
+
}
|
| 2073 |
+
],
|
| 2074 |
+
"dependencies": {
|
| 2075 |
+
"postcss-selector-parser": "^6.1.1"
|
| 2076 |
+
},
|
| 2077 |
+
"engines": {
|
| 2078 |
+
"node": ">=12.0"
|
| 2079 |
+
},
|
| 2080 |
+
"peerDependencies": {
|
| 2081 |
+
"postcss": "^8.2.14"
|
| 2082 |
+
}
|
| 2083 |
+
},
|
| 2084 |
+
"node_modules/postcss-selector-parser": {
|
| 2085 |
+
"version": "6.1.2",
|
| 2086 |
+
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
| 2087 |
+
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
| 2088 |
+
"dev": true,
|
| 2089 |
+
"dependencies": {
|
| 2090 |
+
"cssesc": "^3.0.0",
|
| 2091 |
+
"util-deprecate": "^1.0.2"
|
| 2092 |
+
},
|
| 2093 |
+
"engines": {
|
| 2094 |
+
"node": ">=4"
|
| 2095 |
+
}
|
| 2096 |
+
},
|
| 2097 |
+
"node_modules/postcss-value-parser": {
|
| 2098 |
+
"version": "4.2.0",
|
| 2099 |
+
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
| 2100 |
+
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
| 2101 |
+
"dev": true
|
| 2102 |
+
},
|
| 2103 |
+
"node_modules/queue-microtask": {
|
| 2104 |
+
"version": "1.2.3",
|
| 2105 |
+
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
| 2106 |
+
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
| 2107 |
+
"dev": true,
|
| 2108 |
+
"funding": [
|
| 2109 |
+
{
|
| 2110 |
+
"type": "github",
|
| 2111 |
+
"url": "https://github.com/sponsors/feross"
|
| 2112 |
+
},
|
| 2113 |
+
{
|
| 2114 |
+
"type": "patreon",
|
| 2115 |
+
"url": "https://www.patreon.com/feross"
|
| 2116 |
+
},
|
| 2117 |
+
{
|
| 2118 |
+
"type": "consulting",
|
| 2119 |
+
"url": "https://feross.org/support"
|
| 2120 |
+
}
|
| 2121 |
+
]
|
| 2122 |
+
},
|
| 2123 |
+
"node_modules/react": {
|
| 2124 |
+
"version": "19.2.5",
|
| 2125 |
+
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
| 2126 |
+
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
| 2127 |
+
"engines": {
|
| 2128 |
+
"node": ">=0.10.0"
|
| 2129 |
+
}
|
| 2130 |
+
},
|
| 2131 |
+
"node_modules/react-dom": {
|
| 2132 |
+
"version": "19.2.5",
|
| 2133 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
| 2134 |
+
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
| 2135 |
+
"dependencies": {
|
| 2136 |
+
"scheduler": "^0.27.0"
|
| 2137 |
+
},
|
| 2138 |
+
"peerDependencies": {
|
| 2139 |
+
"react": "^19.2.5"
|
| 2140 |
+
}
|
| 2141 |
+
},
|
| 2142 |
+
"node_modules/react-refresh": {
|
| 2143 |
+
"version": "0.17.0",
|
| 2144 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
| 2145 |
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
| 2146 |
+
"dev": true,
|
| 2147 |
+
"engines": {
|
| 2148 |
+
"node": ">=0.10.0"
|
| 2149 |
+
}
|
| 2150 |
+
},
|
| 2151 |
+
"node_modules/read-cache": {
|
| 2152 |
+
"version": "1.0.0",
|
| 2153 |
+
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
| 2154 |
+
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
| 2155 |
+
"dev": true,
|
| 2156 |
+
"dependencies": {
|
| 2157 |
+
"pify": "^2.3.0"
|
| 2158 |
+
}
|
| 2159 |
+
},
|
| 2160 |
+
"node_modules/readdirp": {
|
| 2161 |
+
"version": "3.6.0",
|
| 2162 |
+
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
| 2163 |
+
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
| 2164 |
+
"dev": true,
|
| 2165 |
+
"dependencies": {
|
| 2166 |
+
"picomatch": "^2.2.1"
|
| 2167 |
+
},
|
| 2168 |
+
"engines": {
|
| 2169 |
+
"node": ">=8.10.0"
|
| 2170 |
+
}
|
| 2171 |
+
},
|
| 2172 |
+
"node_modules/resolve": {
|
| 2173 |
+
"version": "1.22.12",
|
| 2174 |
+
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
| 2175 |
+
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
| 2176 |
+
"dev": true,
|
| 2177 |
+
"dependencies": {
|
| 2178 |
+
"es-errors": "^1.3.0",
|
| 2179 |
+
"is-core-module": "^2.16.1",
|
| 2180 |
+
"path-parse": "^1.0.7",
|
| 2181 |
+
"supports-preserve-symlinks-flag": "^1.0.0"
|
| 2182 |
+
},
|
| 2183 |
+
"bin": {
|
| 2184 |
+
"resolve": "bin/resolve"
|
| 2185 |
+
},
|
| 2186 |
+
"engines": {
|
| 2187 |
+
"node": ">= 0.4"
|
| 2188 |
+
},
|
| 2189 |
+
"funding": {
|
| 2190 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 2191 |
+
}
|
| 2192 |
+
},
|
| 2193 |
+
"node_modules/reusify": {
|
| 2194 |
+
"version": "1.1.0",
|
| 2195 |
+
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
| 2196 |
+
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
| 2197 |
+
"dev": true,
|
| 2198 |
+
"engines": {
|
| 2199 |
+
"iojs": ">=1.0.0",
|
| 2200 |
+
"node": ">=0.10.0"
|
| 2201 |
+
}
|
| 2202 |
+
},
|
| 2203 |
+
"node_modules/rollup": {
|
| 2204 |
+
"version": "4.60.2",
|
| 2205 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
| 2206 |
+
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
| 2207 |
+
"dev": true,
|
| 2208 |
+
"dependencies": {
|
| 2209 |
+
"@types/estree": "1.0.8"
|
| 2210 |
+
},
|
| 2211 |
+
"bin": {
|
| 2212 |
+
"rollup": "dist/bin/rollup"
|
| 2213 |
+
},
|
| 2214 |
+
"engines": {
|
| 2215 |
+
"node": ">=18.0.0",
|
| 2216 |
+
"npm": ">=8.0.0"
|
| 2217 |
+
},
|
| 2218 |
+
"optionalDependencies": {
|
| 2219 |
+
"@rollup/rollup-android-arm-eabi": "4.60.2",
|
| 2220 |
+
"@rollup/rollup-android-arm64": "4.60.2",
|
| 2221 |
+
"@rollup/rollup-darwin-arm64": "4.60.2",
|
| 2222 |
+
"@rollup/rollup-darwin-x64": "4.60.2",
|
| 2223 |
+
"@rollup/rollup-freebsd-arm64": "4.60.2",
|
| 2224 |
+
"@rollup/rollup-freebsd-x64": "4.60.2",
|
| 2225 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
|
| 2226 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
|
| 2227 |
+
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
|
| 2228 |
+
"@rollup/rollup-linux-arm64-musl": "4.60.2",
|
| 2229 |
+
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
|
| 2230 |
+
"@rollup/rollup-linux-loong64-musl": "4.60.2",
|
| 2231 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
|
| 2232 |
+
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
|
| 2233 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
|
| 2234 |
+
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
|
| 2235 |
+
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
|
| 2236 |
+
"@rollup/rollup-linux-x64-gnu": "4.60.2",
|
| 2237 |
+
"@rollup/rollup-linux-x64-musl": "4.60.2",
|
| 2238 |
+
"@rollup/rollup-openbsd-x64": "4.60.2",
|
| 2239 |
+
"@rollup/rollup-openharmony-arm64": "4.60.2",
|
| 2240 |
+
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
|
| 2241 |
+
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
|
| 2242 |
+
"@rollup/rollup-win32-x64-gnu": "4.60.2",
|
| 2243 |
+
"@rollup/rollup-win32-x64-msvc": "4.60.2",
|
| 2244 |
+
"fsevents": "~2.3.2"
|
| 2245 |
+
}
|
| 2246 |
+
},
|
| 2247 |
+
"node_modules/run-parallel": {
|
| 2248 |
+
"version": "1.2.0",
|
| 2249 |
+
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
| 2250 |
+
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
| 2251 |
+
"dev": true,
|
| 2252 |
+
"funding": [
|
| 2253 |
+
{
|
| 2254 |
+
"type": "github",
|
| 2255 |
+
"url": "https://github.com/sponsors/feross"
|
| 2256 |
+
},
|
| 2257 |
+
{
|
| 2258 |
+
"type": "patreon",
|
| 2259 |
+
"url": "https://www.patreon.com/feross"
|
| 2260 |
+
},
|
| 2261 |
+
{
|
| 2262 |
+
"type": "consulting",
|
| 2263 |
+
"url": "https://feross.org/support"
|
| 2264 |
+
}
|
| 2265 |
+
],
|
| 2266 |
+
"dependencies": {
|
| 2267 |
+
"queue-microtask": "^1.2.2"
|
| 2268 |
+
}
|
| 2269 |
+
},
|
| 2270 |
+
"node_modules/scheduler": {
|
| 2271 |
+
"version": "0.27.0",
|
| 2272 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
| 2273 |
+
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
|
| 2274 |
+
},
|
| 2275 |
+
"node_modules/semver": {
|
| 2276 |
+
"version": "6.3.1",
|
| 2277 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 2278 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 2279 |
+
"dev": true,
|
| 2280 |
+
"bin": {
|
| 2281 |
+
"semver": "bin/semver.js"
|
| 2282 |
+
}
|
| 2283 |
+
},
|
| 2284 |
+
"node_modules/source-map-js": {
|
| 2285 |
+
"version": "1.2.1",
|
| 2286 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 2287 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 2288 |
+
"dev": true,
|
| 2289 |
+
"engines": {
|
| 2290 |
+
"node": ">=0.10.0"
|
| 2291 |
+
}
|
| 2292 |
+
},
|
| 2293 |
+
"node_modules/sucrase": {
|
| 2294 |
+
"version": "3.35.1",
|
| 2295 |
+
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
| 2296 |
+
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
| 2297 |
+
"dev": true,
|
| 2298 |
+
"dependencies": {
|
| 2299 |
+
"@jridgewell/gen-mapping": "^0.3.2",
|
| 2300 |
+
"commander": "^4.0.0",
|
| 2301 |
+
"lines-and-columns": "^1.1.6",
|
| 2302 |
+
"mz": "^2.7.0",
|
| 2303 |
+
"pirates": "^4.0.1",
|
| 2304 |
+
"tinyglobby": "^0.2.11",
|
| 2305 |
+
"ts-interface-checker": "^0.1.9"
|
| 2306 |
+
},
|
| 2307 |
+
"bin": {
|
| 2308 |
+
"sucrase": "bin/sucrase",
|
| 2309 |
+
"sucrase-node": "bin/sucrase-node"
|
| 2310 |
+
},
|
| 2311 |
+
"engines": {
|
| 2312 |
+
"node": ">=16 || 14 >=14.17"
|
| 2313 |
+
}
|
| 2314 |
+
},
|
| 2315 |
+
"node_modules/supports-preserve-symlinks-flag": {
|
| 2316 |
+
"version": "1.0.0",
|
| 2317 |
+
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
| 2318 |
+
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
| 2319 |
+
"dev": true,
|
| 2320 |
+
"engines": {
|
| 2321 |
+
"node": ">= 0.4"
|
| 2322 |
+
},
|
| 2323 |
+
"funding": {
|
| 2324 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 2325 |
+
}
|
| 2326 |
+
},
|
| 2327 |
+
"node_modules/tailwindcss": {
|
| 2328 |
+
"version": "3.4.19",
|
| 2329 |
+
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
| 2330 |
+
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
| 2331 |
+
"dev": true,
|
| 2332 |
+
"dependencies": {
|
| 2333 |
+
"@alloc/quick-lru": "^5.2.0",
|
| 2334 |
+
"arg": "^5.0.2",
|
| 2335 |
+
"chokidar": "^3.6.0",
|
| 2336 |
+
"didyoumean": "^1.2.2",
|
| 2337 |
+
"dlv": "^1.1.3",
|
| 2338 |
+
"fast-glob": "^3.3.2",
|
| 2339 |
+
"glob-parent": "^6.0.2",
|
| 2340 |
+
"is-glob": "^4.0.3",
|
| 2341 |
+
"jiti": "^1.21.7",
|
| 2342 |
+
"lilconfig": "^3.1.3",
|
| 2343 |
+
"micromatch": "^4.0.8",
|
| 2344 |
+
"normalize-path": "^3.0.0",
|
| 2345 |
+
"object-hash": "^3.0.0",
|
| 2346 |
+
"picocolors": "^1.1.1",
|
| 2347 |
+
"postcss": "^8.4.47",
|
| 2348 |
+
"postcss-import": "^15.1.0",
|
| 2349 |
+
"postcss-js": "^4.0.1",
|
| 2350 |
+
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
|
| 2351 |
+
"postcss-nested": "^6.2.0",
|
| 2352 |
+
"postcss-selector-parser": "^6.1.2",
|
| 2353 |
+
"resolve": "^1.22.8",
|
| 2354 |
+
"sucrase": "^3.35.0"
|
| 2355 |
+
},
|
| 2356 |
+
"bin": {
|
| 2357 |
+
"tailwind": "lib/cli.js",
|
| 2358 |
+
"tailwindcss": "lib/cli.js"
|
| 2359 |
+
},
|
| 2360 |
+
"engines": {
|
| 2361 |
+
"node": ">=14.0.0"
|
| 2362 |
+
}
|
| 2363 |
+
},
|
| 2364 |
+
"node_modules/thenify": {
|
| 2365 |
+
"version": "3.3.1",
|
| 2366 |
+
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
| 2367 |
+
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
| 2368 |
+
"dev": true,
|
| 2369 |
+
"dependencies": {
|
| 2370 |
+
"any-promise": "^1.0.0"
|
| 2371 |
+
}
|
| 2372 |
+
},
|
| 2373 |
+
"node_modules/thenify-all": {
|
| 2374 |
+
"version": "1.6.0",
|
| 2375 |
+
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
| 2376 |
+
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
| 2377 |
+
"dev": true,
|
| 2378 |
+
"dependencies": {
|
| 2379 |
+
"thenify": ">= 3.1.0 < 4"
|
| 2380 |
+
},
|
| 2381 |
+
"engines": {
|
| 2382 |
+
"node": ">=0.8"
|
| 2383 |
+
}
|
| 2384 |
+
},
|
| 2385 |
+
"node_modules/tinyglobby": {
|
| 2386 |
+
"version": "0.2.16",
|
| 2387 |
+
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
| 2388 |
+
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
| 2389 |
+
"dev": true,
|
| 2390 |
+
"dependencies": {
|
| 2391 |
+
"fdir": "^6.5.0",
|
| 2392 |
+
"picomatch": "^4.0.4"
|
| 2393 |
+
},
|
| 2394 |
+
"engines": {
|
| 2395 |
+
"node": ">=12.0.0"
|
| 2396 |
+
},
|
| 2397 |
+
"funding": {
|
| 2398 |
+
"url": "https://github.com/sponsors/SuperchupuDev"
|
| 2399 |
+
}
|
| 2400 |
+
},
|
| 2401 |
+
"node_modules/tinyglobby/node_modules/fdir": {
|
| 2402 |
+
"version": "6.5.0",
|
| 2403 |
+
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
| 2404 |
+
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
| 2405 |
+
"dev": true,
|
| 2406 |
+
"engines": {
|
| 2407 |
+
"node": ">=12.0.0"
|
| 2408 |
+
},
|
| 2409 |
+
"peerDependencies": {
|
| 2410 |
+
"picomatch": "^3 || ^4"
|
| 2411 |
+
},
|
| 2412 |
+
"peerDependenciesMeta": {
|
| 2413 |
+
"picomatch": {
|
| 2414 |
+
"optional": true
|
| 2415 |
+
}
|
| 2416 |
+
}
|
| 2417 |
+
},
|
| 2418 |
+
"node_modules/tinyglobby/node_modules/picomatch": {
|
| 2419 |
+
"version": "4.0.4",
|
| 2420 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 2421 |
+
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 2422 |
+
"dev": true,
|
| 2423 |
+
"engines": {
|
| 2424 |
+
"node": ">=12"
|
| 2425 |
+
},
|
| 2426 |
+
"funding": {
|
| 2427 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 2428 |
+
}
|
| 2429 |
+
},
|
| 2430 |
+
"node_modules/to-regex-range": {
|
| 2431 |
+
"version": "5.0.1",
|
| 2432 |
+
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
| 2433 |
+
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
| 2434 |
+
"dev": true,
|
| 2435 |
+
"dependencies": {
|
| 2436 |
+
"is-number": "^7.0.0"
|
| 2437 |
+
},
|
| 2438 |
+
"engines": {
|
| 2439 |
+
"node": ">=8.0"
|
| 2440 |
+
}
|
| 2441 |
+
},
|
| 2442 |
+
"node_modules/ts-interface-checker": {
|
| 2443 |
+
"version": "0.1.13",
|
| 2444 |
+
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
| 2445 |
+
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
| 2446 |
+
"dev": true
|
| 2447 |
+
},
|
| 2448 |
+
"node_modules/tslib": {
|
| 2449 |
+
"version": "2.8.1",
|
| 2450 |
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
| 2451 |
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
| 2452 |
+
},
|
| 2453 |
+
"node_modules/typescript": {
|
| 2454 |
+
"version": "5.6.3",
|
| 2455 |
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
| 2456 |
+
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
| 2457 |
+
"dev": true,
|
| 2458 |
+
"bin": {
|
| 2459 |
+
"tsc": "bin/tsc",
|
| 2460 |
+
"tsserver": "bin/tsserver"
|
| 2461 |
+
},
|
| 2462 |
+
"engines": {
|
| 2463 |
+
"node": ">=14.17"
|
| 2464 |
+
}
|
| 2465 |
+
},
|
| 2466 |
+
"node_modules/update-browserslist-db": {
|
| 2467 |
+
"version": "1.2.3",
|
| 2468 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
| 2469 |
+
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
| 2470 |
+
"dev": true,
|
| 2471 |
+
"funding": [
|
| 2472 |
+
{
|
| 2473 |
+
"type": "opencollective",
|
| 2474 |
+
"url": "https://opencollective.com/browserslist"
|
| 2475 |
+
},
|
| 2476 |
+
{
|
| 2477 |
+
"type": "tidelift",
|
| 2478 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 2479 |
+
},
|
| 2480 |
+
{
|
| 2481 |
+
"type": "github",
|
| 2482 |
+
"url": "https://github.com/sponsors/ai"
|
| 2483 |
+
}
|
| 2484 |
+
],
|
| 2485 |
+
"dependencies": {
|
| 2486 |
+
"escalade": "^3.2.0",
|
| 2487 |
+
"picocolors": "^1.1.1"
|
| 2488 |
+
},
|
| 2489 |
+
"bin": {
|
| 2490 |
+
"update-browserslist-db": "cli.js"
|
| 2491 |
+
},
|
| 2492 |
+
"peerDependencies": {
|
| 2493 |
+
"browserslist": ">= 4.21.0"
|
| 2494 |
+
}
|
| 2495 |
+
},
|
| 2496 |
+
"node_modules/util-deprecate": {
|
| 2497 |
+
"version": "1.0.2",
|
| 2498 |
+
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
| 2499 |
+
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
| 2500 |
+
"dev": true
|
| 2501 |
+
},
|
| 2502 |
+
"node_modules/vite": {
|
| 2503 |
+
"version": "6.4.2",
|
| 2504 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
| 2505 |
+
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
| 2506 |
+
"dev": true,
|
| 2507 |
+
"dependencies": {
|
| 2508 |
+
"esbuild": "^0.25.0",
|
| 2509 |
+
"fdir": "^6.4.4",
|
| 2510 |
+
"picomatch": "^4.0.2",
|
| 2511 |
+
"postcss": "^8.5.3",
|
| 2512 |
+
"rollup": "^4.34.9",
|
| 2513 |
+
"tinyglobby": "^0.2.13"
|
| 2514 |
+
},
|
| 2515 |
+
"bin": {
|
| 2516 |
+
"vite": "bin/vite.js"
|
| 2517 |
+
},
|
| 2518 |
+
"engines": {
|
| 2519 |
+
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
| 2520 |
+
},
|
| 2521 |
+
"funding": {
|
| 2522 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 2523 |
+
},
|
| 2524 |
+
"optionalDependencies": {
|
| 2525 |
+
"fsevents": "~2.3.3"
|
| 2526 |
+
},
|
| 2527 |
+
"peerDependencies": {
|
| 2528 |
+
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
| 2529 |
+
"jiti": ">=1.21.0",
|
| 2530 |
+
"less": "*",
|
| 2531 |
+
"lightningcss": "^1.21.0",
|
| 2532 |
+
"sass": "*",
|
| 2533 |
+
"sass-embedded": "*",
|
| 2534 |
+
"stylus": "*",
|
| 2535 |
+
"sugarss": "*",
|
| 2536 |
+
"terser": "^5.16.0",
|
| 2537 |
+
"tsx": "^4.8.1",
|
| 2538 |
+
"yaml": "^2.4.2"
|
| 2539 |
+
},
|
| 2540 |
+
"peerDependenciesMeta": {
|
| 2541 |
+
"@types/node": {
|
| 2542 |
+
"optional": true
|
| 2543 |
+
},
|
| 2544 |
+
"jiti": {
|
| 2545 |
+
"optional": true
|
| 2546 |
+
},
|
| 2547 |
+
"less": {
|
| 2548 |
+
"optional": true
|
| 2549 |
+
},
|
| 2550 |
+
"lightningcss": {
|
| 2551 |
+
"optional": true
|
| 2552 |
+
},
|
| 2553 |
+
"sass": {
|
| 2554 |
+
"optional": true
|
| 2555 |
+
},
|
| 2556 |
+
"sass-embedded": {
|
| 2557 |
+
"optional": true
|
| 2558 |
+
},
|
| 2559 |
+
"stylus": {
|
| 2560 |
+
"optional": true
|
| 2561 |
+
},
|
| 2562 |
+
"sugarss": {
|
| 2563 |
+
"optional": true
|
| 2564 |
+
},
|
| 2565 |
+
"terser": {
|
| 2566 |
+
"optional": true
|
| 2567 |
+
},
|
| 2568 |
+
"tsx": {
|
| 2569 |
+
"optional": true
|
| 2570 |
+
},
|
| 2571 |
+
"yaml": {
|
| 2572 |
+
"optional": true
|
| 2573 |
+
}
|
| 2574 |
+
}
|
| 2575 |
+
},
|
| 2576 |
+
"node_modules/vite/node_modules/fdir": {
|
| 2577 |
+
"version": "6.5.0",
|
| 2578 |
+
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
| 2579 |
+
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
| 2580 |
+
"dev": true,
|
| 2581 |
+
"engines": {
|
| 2582 |
+
"node": ">=12.0.0"
|
| 2583 |
+
},
|
| 2584 |
+
"peerDependencies": {
|
| 2585 |
+
"picomatch": "^3 || ^4"
|
| 2586 |
+
},
|
| 2587 |
+
"peerDependenciesMeta": {
|
| 2588 |
+
"picomatch": {
|
| 2589 |
+
"optional": true
|
| 2590 |
+
}
|
| 2591 |
+
}
|
| 2592 |
+
},
|
| 2593 |
+
"node_modules/vite/node_modules/picomatch": {
|
| 2594 |
+
"version": "4.0.4",
|
| 2595 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 2596 |
+
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 2597 |
+
"dev": true,
|
| 2598 |
+
"engines": {
|
| 2599 |
+
"node": ">=12"
|
| 2600 |
+
},
|
| 2601 |
+
"funding": {
|
| 2602 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 2603 |
+
}
|
| 2604 |
+
},
|
| 2605 |
+
"node_modules/yallist": {
|
| 2606 |
+
"version": "3.1.1",
|
| 2607 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 2608 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 2609 |
+
"dev": true
|
| 2610 |
+
}
|
| 2611 |
+
}
|
| 2612 |
+
}
|
frontend/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "product-showcase-studio",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^19.0.0",
|
| 13 |
+
"react-dom": "^19.0.0",
|
| 14 |
+
"framer-motion": "^11.15.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@types/react": "^19.0.0",
|
| 18 |
+
"@types/react-dom": "^19.0.0",
|
| 19 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 20 |
+
"autoprefixer": "^10.4.20",
|
| 21 |
+
"postcss": "^8.4.49",
|
| 22 |
+
"tailwindcss": "^3.4.17",
|
| 23 |
+
"typescript": "~5.6.0",
|
| 24 |
+
"vite": "^6.0.0"
|
| 25 |
+
}
|
| 26 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { checkHealth, type HealthStatus } from '@/api';
|
| 4 |
+
import { ShowcaseFlow } from '@/components/ShowcaseFlow';
|
| 5 |
+
|
| 6 |
+
function StatusDot({ ok }: { ok: boolean }) {
|
| 7 |
+
return (
|
| 8 |
+
<span
|
| 9 |
+
className={`h-1.5 w-1.5 shrink-0 rounded-full ${ok ? 'bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.6)]' : 'bg-red-400'}`}
|
| 10 |
+
aria-hidden
|
| 11 |
+
/>
|
| 12 |
+
);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function App() {
|
| 16 |
+
const [health, setHealth] = useState<HealthStatus | null>(null);
|
| 17 |
+
const [healthOk, setHealthOk] = useState(false);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
checkHealth()
|
| 21 |
+
.then((h) => {
|
| 22 |
+
setHealth(h);
|
| 23 |
+
setHealthOk(h.status === 'healthy');
|
| 24 |
+
})
|
| 25 |
+
.catch(() => {
|
| 26 |
+
setHealth(null);
|
| 27 |
+
setHealthOk(false);
|
| 28 |
+
});
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="studio-page-bg">
|
| 33 |
+
<header className="studio-header">
|
| 34 |
+
<div className="studio-shell flex flex-col gap-5 py-6 sm:flex-row sm:items-end sm:justify-between sm:py-7">
|
| 35 |
+
<div className="max-w-xl">
|
| 36 |
+
<p className="mb-2 text-[0.65rem] font-semibold uppercase tracking-[0.2em] text-accent/90">
|
| 37 |
+
Launch films
|
| 38 |
+
</p>
|
| 39 |
+
<motion.h1
|
| 40 |
+
initial={{ opacity: 0, y: 8 }}
|
| 41 |
+
animate={{ opacity: 1, y: 0 }}
|
| 42 |
+
transition={{ duration: 0.35 }}
|
| 43 |
+
className="font-display text-3xl font-semibold leading-tight tracking-tight text-slate-900 sm:text-4xl"
|
| 44 |
+
>
|
| 45 |
+
Product Showcase
|
| 46 |
+
<span className="text-slate-400"> Studio</span>
|
| 47 |
+
</motion.h1>
|
| 48 |
+
<p className="mt-3 text-sm leading-relaxed text-slate-600">
|
| 49 |
+
Plan shots, align the product with GPT Image keyframes, animate with Veo — then merge one master
|
| 50 |
+
cut.
|
| 51 |
+
</p>
|
| 52 |
+
</div>
|
| 53 |
+
{health && (
|
| 54 |
+
<div className="flex flex-wrap gap-2 sm:justify-end">
|
| 55 |
+
<span
|
| 56 |
+
className={`studio-pill ${
|
| 57 |
+
healthOk
|
| 58 |
+
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
|
| 59 |
+
: 'border-red-500/35 bg-red-500/10 text-red-100'
|
| 60 |
+
}`}
|
| 61 |
+
>
|
| 62 |
+
<StatusDot ok={healthOk} />
|
| 63 |
+
API {healthOk ? 'live' : 'unreachable'}
|
| 64 |
+
</span>
|
| 65 |
+
<span
|
| 66 |
+
className={`studio-pill ${
|
| 67 |
+
health.kie_configured
|
| 68 |
+
? 'border-slate-300 bg-white text-slate-700'
|
| 69 |
+
: 'border-amber-500/35 bg-amber-500/10 text-amber-100'
|
| 70 |
+
}`}
|
| 71 |
+
>
|
| 72 |
+
<StatusDot ok={health.kie_configured} />
|
| 73 |
+
KIE
|
| 74 |
+
</span>
|
| 75 |
+
{health.replicate_configured && (
|
| 76 |
+
<span className="studio-pill border-slate-300 bg-white text-slate-700">
|
| 77 |
+
<StatusDot ok={true} />
|
| 78 |
+
Replicate
|
| 79 |
+
</span>
|
| 80 |
+
)}
|
| 81 |
+
<span
|
| 82 |
+
className={`studio-pill ${
|
| 83 |
+
health.openai_configured
|
| 84 |
+
? 'border-slate-300 bg-white text-slate-700'
|
| 85 |
+
: 'border-slate-300 bg-slate-100 text-slate-500'
|
| 86 |
+
}`}
|
| 87 |
+
>
|
| 88 |
+
<StatusDot ok={health.openai_configured} />
|
| 89 |
+
OpenAI
|
| 90 |
+
</span>
|
| 91 |
+
{health.ffmpeg_available === false && (
|
| 92 |
+
<span className="studio-pill border-amber-500/35 bg-amber-500/10 text-amber-100">
|
| 93 |
+
FFmpeg missing
|
| 94 |
+
</span>
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
</div>
|
| 99 |
+
</header>
|
| 100 |
+
<main className="studio-shell py-8 sm:py-12">
|
| 101 |
+
<ShowcaseFlow health={health} />
|
| 102 |
+
</main>
|
| 103 |
+
<footer className="border-t border-slate-200 py-6 text-center text-[0.7rem] text-slate-500">
|
| 104 |
+
Product Showcase Studio — local pipeline
|
| 105 |
+
</footer>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
}
|
frontend/src/api.ts
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ClipMetadata, SegmentsPayload, StreamEvent, VeoSegment } from '@/types';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* In dev, use same-origin `/api` so the Vite proxy handles SSE (EventSource) without CORS.
|
| 5 |
+
* Ngrok and other tunnels often omit Access-Control-Allow-Origin on streamed responses.
|
| 6 |
+
* Set `VITE_PUBLIC_API_IN_DEV=true` only if your API sends proper CORS for localhost.
|
| 7 |
+
* Production: set `VITE_API_BASE_URL` to your deployed API origin.
|
| 8 |
+
*/
|
| 9 |
+
const API_BASE =
|
| 10 |
+
import.meta.env.DEV && import.meta.env.VITE_PUBLIC_API_IN_DEV !== 'true'
|
| 11 |
+
? ''
|
| 12 |
+
: (import.meta.env.VITE_API_BASE_URL || '');
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Hero preview in the UI: prefer the storefront CDN URL when we have it (always a real image).
|
| 16 |
+
* Otherwise use blob/data URLs as-is. For hosted /api/images/... URLs, use a path-only URL in dev
|
| 17 |
+
* so the Vite proxy loads pixels (ngrok-free and some tunnels return HTML for absolute image GETs).
|
| 18 |
+
*/
|
| 19 |
+
export function imageSrcForHeroPreview(
|
| 20 |
+
heroRemoteUrl: string | null,
|
| 21 |
+
imagePreview: string | null
|
| 22 |
+
): string | null {
|
| 23 |
+
if (heroRemoteUrl) return heroRemoteUrl;
|
| 24 |
+
if (!imagePreview) return null;
|
| 25 |
+
if (imagePreview.startsWith('blob:') || imagePreview.startsWith('data:')) return imagePreview;
|
| 26 |
+
|
| 27 |
+
if (imagePreview.includes('/api/images/') && import.meta.env.DEV) {
|
| 28 |
+
try {
|
| 29 |
+
return new URL(imagePreview).pathname;
|
| 30 |
+
} catch {
|
| 31 |
+
return imagePreview.startsWith('/') ? imagePreview : null;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return imagePreview;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const GPT_IMAGE_EDIT_MAX_REFS = 4;
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Build 2–4 distinct product image URLs for GPT Image `edits` (e.g. gpt-image-2).
|
| 42 |
+
* Merges the hosted hero (this run), storefront hero, and scraped gallery so models
|
| 43 |
+
* see multiple angles when available — not just a single frame.
|
| 44 |
+
*/
|
| 45 |
+
export function pickProductReferenceUrlsForGpt(options: {
|
| 46 |
+
hostedUrl: string | null;
|
| 47 |
+
heroRemoteUrl: string | null;
|
| 48 |
+
scrapedImageUrls: string[];
|
| 49 |
+
max?: number;
|
| 50 |
+
}): string[] {
|
| 51 |
+
const max = Math.min(GPT_IMAGE_EDIT_MAX_REFS, Math.max(1, options.max ?? GPT_IMAGE_EDIT_MAX_REFS));
|
| 52 |
+
const out: string[] = [];
|
| 53 |
+
const seen = new Set<string>();
|
| 54 |
+
|
| 55 |
+
const push = (u: string | null | undefined): boolean => {
|
| 56 |
+
const s = (u ?? '').trim();
|
| 57 |
+
if (!s || seen.has(s)) return false;
|
| 58 |
+
seen.add(s);
|
| 59 |
+
out.push(s);
|
| 60 |
+
return out.length >= max;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
if (push(options.hostedUrl)) return out;
|
| 64 |
+
if (push(options.heroRemoteUrl)) return out;
|
| 65 |
+
for (const u of options.scrapedImageUrls) {
|
| 66 |
+
if (push(u)) break;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return out;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
async function parseError(res: Response, fallback: string): Promise<string> {
|
| 73 |
+
const ct = res.headers.get('content-type');
|
| 74 |
+
try {
|
| 75 |
+
if (ct?.includes('application/json')) {
|
| 76 |
+
const j = await res.json();
|
| 77 |
+
return j.detail || j.message || fallback;
|
| 78 |
+
}
|
| 79 |
+
const t = await res.text();
|
| 80 |
+
return t?.trim() || fallback;
|
| 81 |
+
} catch {
|
| 82 |
+
return fallback;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export interface HealthStatus {
|
| 87 |
+
status: string;
|
| 88 |
+
service?: string;
|
| 89 |
+
kie_configured: boolean;
|
| 90 |
+
/** Seedance can fall back to Replicate when KIE fails if this is true */
|
| 91 |
+
replicate_configured?: boolean;
|
| 92 |
+
openai_configured: boolean;
|
| 93 |
+
gpt_image_model?: string;
|
| 94 |
+
ffmpeg_available?: boolean;
|
| 95 |
+
ffprobe_available?: boolean;
|
| 96 |
+
public_base_url?: string;
|
| 97 |
+
server_port?: number;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export async function checkHealth(): Promise<HealthStatus> {
|
| 101 |
+
const url = `${API_BASE}/health`;
|
| 102 |
+
const res = await fetch(url);
|
| 103 |
+
if (!res.ok) throw new Error(await parseError(res, 'Health check failed'));
|
| 104 |
+
return res.json();
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export async function uploadImage(file: File): Promise<{ url: string }> {
|
| 108 |
+
const fd = new FormData();
|
| 109 |
+
fd.append('file', file);
|
| 110 |
+
const res = await fetch(`${API_BASE}/api/upload-image`, { method: 'POST', body: fd });
|
| 111 |
+
if (!res.ok) throw new Error(await parseError(res, 'Upload failed'));
|
| 112 |
+
return res.json();
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export interface ScrapeProductResponse {
|
| 116 |
+
product_name: string;
|
| 117 |
+
description: string;
|
| 118 |
+
price: string;
|
| 119 |
+
offers: string;
|
| 120 |
+
product_images: string;
|
| 121 |
+
brand: string;
|
| 122 |
+
category: string;
|
| 123 |
+
image_urls: string[];
|
| 124 |
+
source_url: string;
|
| 125 |
+
[key: string]: unknown;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export async function scrapeProductPage(url: string): Promise<ScrapeProductResponse> {
|
| 129 |
+
const res = await fetch(`${API_BASE}/api/showcase/scrape`, {
|
| 130 |
+
method: 'POST',
|
| 131 |
+
headers: { 'Content-Type': 'application/json' },
|
| 132 |
+
body: JSON.stringify({ url }),
|
| 133 |
+
});
|
| 134 |
+
if (!res.ok) throw new Error(await parseError(res, 'Scrape failed'));
|
| 135 |
+
return res.json();
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
export async function hostImageFromUrl(url: string): Promise<{ url: string; source_url: string }> {
|
| 139 |
+
const res = await fetch(`${API_BASE}/api/host-image-url`, {
|
| 140 |
+
method: 'POST',
|
| 141 |
+
headers: { 'Content-Type': 'application/json' },
|
| 142 |
+
body: JSON.stringify({ url }),
|
| 143 |
+
});
|
| 144 |
+
if (!res.ok) throw new Error(await parseError(res, 'Could not host image'));
|
| 145 |
+
return res.json();
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
export async function generateDirectConcepts(params: {
|
| 149 |
+
productName: string;
|
| 150 |
+
tagline?: string;
|
| 151 |
+
mood?: string;
|
| 152 |
+
features?: string;
|
| 153 |
+
count?: number;
|
| 154 |
+
}): Promise<{ concepts: string[] }> {
|
| 155 |
+
const res = await fetch(`${API_BASE}/api/showcase/direct-concepts`, {
|
| 156 |
+
method: 'POST',
|
| 157 |
+
headers: { 'Content-Type': 'application/json' },
|
| 158 |
+
body: JSON.stringify({
|
| 159 |
+
product_name: params.productName,
|
| 160 |
+
tagline: params.tagline ?? '',
|
| 161 |
+
mood: params.mood ?? '',
|
| 162 |
+
features: params.features ?? '',
|
| 163 |
+
count: params.count ?? 10,
|
| 164 |
+
}),
|
| 165 |
+
});
|
| 166 |
+
if (!res.ok) throw new Error(await parseError(res, 'Concept generation failed'));
|
| 167 |
+
return res.json();
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
export async function regenerateDirectConcept(params: {
|
| 171 |
+
productName: string;
|
| 172 |
+
tagline?: string;
|
| 173 |
+
mood?: string;
|
| 174 |
+
features?: string;
|
| 175 |
+
exclude?: string[];
|
| 176 |
+
index?: number;
|
| 177 |
+
}): Promise<{ concept: string }> {
|
| 178 |
+
const res = await fetch(`${API_BASE}/api/showcase/direct-concept-one`, {
|
| 179 |
+
method: 'POST',
|
| 180 |
+
headers: { 'Content-Type': 'application/json' },
|
| 181 |
+
body: JSON.stringify({
|
| 182 |
+
product_name: params.productName,
|
| 183 |
+
tagline: params.tagline ?? '',
|
| 184 |
+
mood: params.mood ?? '',
|
| 185 |
+
features: params.features ?? '',
|
| 186 |
+
exclude: params.exclude ?? [],
|
| 187 |
+
index: params.index ?? 1,
|
| 188 |
+
}),
|
| 189 |
+
});
|
| 190 |
+
if (!res.ok) throw new Error(await parseError(res, 'Concept regeneration failed'));
|
| 191 |
+
return res.json();
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/** Must match backend `showcase_prompts._CONCEPTS` keys. */
|
| 195 |
+
export const SHOWCASE_CONCEPT_OPTIONS = [
|
| 196 |
+
{
|
| 197 |
+
id: 'luxury_studio',
|
| 198 |
+
label: 'Luxury studio launch',
|
| 199 |
+
description: 'Slow dolly, pedestal, whisper VO — flagship packshot grammar.',
|
| 200 |
+
category: 'Commercial',
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
id: 'ugc_authentic',
|
| 204 |
+
label: 'UGC / social authentic',
|
| 205 |
+
description: 'Handheld, desk or counter, creator energy — short-form hooks.',
|
| 206 |
+
category: 'Social',
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
id: 'tech_minimal',
|
| 210 |
+
label: 'Tech / minimal',
|
| 211 |
+
description: 'Void set, hard edge light, precise motion — device-film calm.',
|
| 212 |
+
category: 'Commercial',
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
id: 'lifestyle_natural',
|
| 216 |
+
label: 'Lifestyle / natural light',
|
| 217 |
+
description: 'Real rooms, sun paths, slow living — editorial home story.',
|
| 218 |
+
category: 'Commercial',
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
id: 'bold_editorial',
|
| 222 |
+
label: 'Bold / color editorial',
|
| 223 |
+
description: 'Gels, graphic shadows, campaign-poster energy.',
|
| 224 |
+
category: 'Experimental',
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
id: 'unboxing_asmr',
|
| 228 |
+
label: 'Unboxing / desk ASMR',
|
| 229 |
+
description: 'Top-down peel, satisfying motion, whisper pacing.',
|
| 230 |
+
category: 'Social',
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
id: 'high_energy_sports',
|
| 234 |
+
label: 'High-energy sports',
|
| 235 |
+
description: 'Kinetic camera, impact cuts, performance-first momentum.',
|
| 236 |
+
category: 'Commercial',
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
id: 'moody_cinematic_noir',
|
| 240 |
+
label: 'Moody cinematic noir',
|
| 241 |
+
description: 'Chiaroscuro lighting, suspense pacing, dramatic reveals.',
|
| 242 |
+
category: 'Experimental',
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
id: 'playful_stopmotion_style',
|
| 246 |
+
label: 'Playful stop-motion style',
|
| 247 |
+
description: 'Tabletop whimsy, snappy object beats, colorful transitions.',
|
| 248 |
+
category: 'Experimental',
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
id: 'nature_outdoor_adventure',
|
| 252 |
+
label: 'Nature / outdoor adventure',
|
| 253 |
+
description: 'Golden trails, outdoor scale, utility-forward storytelling.',
|
| 254 |
+
category: 'Commercial',
|
| 255 |
+
},
|
| 256 |
+
] as const;
|
| 257 |
+
|
| 258 |
+
export type ShowcaseConceptId = (typeof SHOWCASE_CONCEPT_OPTIONS)[number]['id'];
|
| 259 |
+
|
| 260 |
+
export async function showcasePlanStream(
|
| 261 |
+
params: {
|
| 262 |
+
productName: string;
|
| 263 |
+
tagline: string;
|
| 264 |
+
mood: string;
|
| 265 |
+
features: string;
|
| 266 |
+
shotCount: number;
|
| 267 |
+
/** Optional override; when omitted backend chooses 4/6/8 from concept + product brief. */
|
| 268 |
+
secondsPerSegment?: number;
|
| 269 |
+
/** Creative arc for shot planning (template + GPT). */
|
| 270 |
+
creativeConcept?: ShowcaseConceptId;
|
| 271 |
+
image?: File | null;
|
| 272 |
+
/** Original CDN URL for vision (optional); file upload takes precedence when present. */
|
| 273 |
+
heroImageUrl?: string;
|
| 274 |
+
},
|
| 275 |
+
onEvent: (e: StreamEvent) => void,
|
| 276 |
+
signal?: AbortSignal
|
| 277 |
+
): Promise<SegmentsPayload> {
|
| 278 |
+
const fd = new FormData();
|
| 279 |
+
fd.append('productName', params.productName);
|
| 280 |
+
fd.append('tagline', params.tagline);
|
| 281 |
+
fd.append('mood', params.mood);
|
| 282 |
+
fd.append('features', params.features);
|
| 283 |
+
fd.append('shotCount', String(params.shotCount));
|
| 284 |
+
if (params.secondsPerSegment != null) {
|
| 285 |
+
fd.append('secondsPerSegment', String(params.secondsPerSegment));
|
| 286 |
+
}
|
| 287 |
+
fd.append('creativeConcept', params.creativeConcept ?? 'luxury_studio');
|
| 288 |
+
if (params.image) fd.append('image', params.image);
|
| 289 |
+
if (params.heroImageUrl) fd.append('heroImageUrl', params.heroImageUrl);
|
| 290 |
+
|
| 291 |
+
const res = await fetch(`${API_BASE}/api/showcase/plan-stream`, {
|
| 292 |
+
method: 'POST',
|
| 293 |
+
body: fd,
|
| 294 |
+
signal,
|
| 295 |
+
});
|
| 296 |
+
if (!res.ok) throw new Error(await parseError(res, 'Shot plan failed'));
|
| 297 |
+
if (!res.body) throw new Error('No response body');
|
| 298 |
+
|
| 299 |
+
const reader = res.body.getReader();
|
| 300 |
+
const dec = new TextDecoder();
|
| 301 |
+
let buf = '';
|
| 302 |
+
let final: SegmentsPayload | null = null;
|
| 303 |
+
|
| 304 |
+
try {
|
| 305 |
+
while (true) {
|
| 306 |
+
const { done, value } = await reader.read();
|
| 307 |
+
if (done) break;
|
| 308 |
+
buf += dec.decode(value, { stream: true });
|
| 309 |
+
const lines = buf.split('\n');
|
| 310 |
+
buf = lines.pop() || '';
|
| 311 |
+
for (const line of lines) {
|
| 312 |
+
if (!line.trim()) continue;
|
| 313 |
+
const ev = JSON.parse(line) as StreamEvent;
|
| 314 |
+
onEvent(ev);
|
| 315 |
+
if (ev.event === 'complete') final = ev.payload;
|
| 316 |
+
if (ev.event === 'error') throw new Error(ev.message);
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
} finally {
|
| 320 |
+
reader.releaseLock();
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
if (!final) throw new Error('Stream ended without complete payload');
|
| 324 |
+
return final;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
export interface KlingGenerateResponse {
|
| 328 |
+
taskId: string;
|
| 329 |
+
status: string;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/** Parse segment_info.duration ("4s", "8s") for Veo / trim. */
|
| 333 |
+
export function segmentClipSeconds(segment: { segment_info?: { duration?: string } }): 4 | 6 | 8 {
|
| 334 |
+
const raw = segment.segment_info?.duration ?? '';
|
| 335 |
+
const m = String(raw).match(/^(\d+)/);
|
| 336 |
+
if (m) {
|
| 337 |
+
const n = parseInt(m[1], 10);
|
| 338 |
+
if (n === 4 || n === 6 || n === 8) return n;
|
| 339 |
+
}
|
| 340 |
+
return 8;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
export type SegmentVideoModel = 'veo3_fast' | 'seedance-2' | 'seedance-2-fast';
|
| 344 |
+
|
| 345 |
+
export type SeedanceSegmentModel = 'seedance-2' | 'seedance-2-fast';
|
| 346 |
+
|
| 347 |
+
/** UI segment model → KIE `jobs/createTask` model id. */
|
| 348 |
+
export function kieSeedanceModelId(model: SeedanceSegmentModel): string {
|
| 349 |
+
return model === 'seedance-2-fast' ? 'bytedance/seedance-2-fast' : 'bytedance/seedance-2';
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
export function isSeedanceSegmentModel(model: SegmentVideoModel): model is SeedanceSegmentModel {
|
| 353 |
+
return model === 'seedance-2' || model === 'seedance-2-fast';
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
/** Flatten structured segment JSON into a Seedance text prompt (max 20k on API). */
|
| 357 |
+
export function segmentToSeedancePrompt(segment: VeoSegment, productName?: string): string {
|
| 358 |
+
const ch = segment.character_description;
|
| 359 |
+
const sc = segment.scene_continuity;
|
| 360 |
+
const at = segment.action_timeline;
|
| 361 |
+
const lines: string[] = [];
|
| 362 |
+
if (productName?.trim()) lines.push(`Product: ${productName.trim()}.`);
|
| 363 |
+
if (ch?.current_state) lines.push(ch.current_state);
|
| 364 |
+
if (sc?.environment) lines.push(sc.environment);
|
| 365 |
+
if (sc?.camera_position) lines.push(`Camera: ${sc.camera_position}`);
|
| 366 |
+
if (sc?.camera_movement) lines.push(`Motion: ${sc.camera_movement}`);
|
| 367 |
+
if (sc?.lighting_state) lines.push(`Lighting: ${sc.lighting_state}`);
|
| 368 |
+
if (sc?.background_elements) lines.push(`Background: ${sc.background_elements}`);
|
| 369 |
+
if (at?.dialogue) lines.push(`VO: ${at.dialogue}`);
|
| 370 |
+
const sync = at?.synchronized_actions;
|
| 371 |
+
if (sync && typeof sync === 'object') {
|
| 372 |
+
for (const [k, v] of Object.entries(sync)) {
|
| 373 |
+
if (v) lines.push(`${k}: ${v}`);
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
let text = lines.filter(Boolean).join('\n').trim();
|
| 377 |
+
if (!text) {
|
| 378 |
+
text =
|
| 379 |
+
'Cinematic premium product showcase, photoreal, smooth camera, shallow depth of field.';
|
| 380 |
+
}
|
| 381 |
+
if (text.length > 20000) text = text.slice(0, 20000);
|
| 382 |
+
return text;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
export async function seedanceCreate(body: {
|
| 386 |
+
prompt: string;
|
| 387 |
+
reference_image_urls: string[];
|
| 388 |
+
aspect_ratio: string;
|
| 389 |
+
duration: number;
|
| 390 |
+
resolution?: string;
|
| 391 |
+
generate_audio?: boolean;
|
| 392 |
+
/** KIE model, e.g. `bytedance/seedance-2` or `bytedance/seedance-2-fast`. */
|
| 393 |
+
model?: string;
|
| 394 |
+
}): Promise<{ taskId: string }> {
|
| 395 |
+
const res = await fetch(`${API_BASE}/api/seedance/create`, {
|
| 396 |
+
method: 'POST',
|
| 397 |
+
headers: { 'Content-Type': 'application/json' },
|
| 398 |
+
body: JSON.stringify(body),
|
| 399 |
+
});
|
| 400 |
+
if (!res.ok) throw new Error(await parseError(res, 'Seedance task failed'));
|
| 401 |
+
return res.json();
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
export function createSeedanceEventSource(taskId: string): EventSource {
|
| 405 |
+
return new EventSource(`${API_BASE}/api/seedance/events/${encodeURIComponent(taskId)}`);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
export async function seedanceStatus(taskId: string): Promise<{
|
| 409 |
+
state?: string;
|
| 410 |
+
url?: string | null;
|
| 411 |
+
failMsg?: string | null;
|
| 412 |
+
}> {
|
| 413 |
+
const res = await fetch(`${API_BASE}/api/seedance/status/${encodeURIComponent(taskId)}`);
|
| 414 |
+
if (!res.ok) throw new Error(await parseError(res, 'Seedance status check failed'));
|
| 415 |
+
return res.json();
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
export function waitForSeedanceVideo(taskId: string, timeoutMs = 600000): Promise<string> {
|
| 419 |
+
return new Promise((resolve, reject) => {
|
| 420 |
+
let settled = false;
|
| 421 |
+
const es = createSeedanceEventSource(taskId);
|
| 422 |
+
const pollEveryMs = 4000;
|
| 423 |
+
const poller = window.setInterval(async () => {
|
| 424 |
+
if (settled) return;
|
| 425 |
+
try {
|
| 426 |
+
const data = await seedanceStatus(taskId);
|
| 427 |
+
if (data.state === 'success' && data.url) {
|
| 428 |
+
settled = true;
|
| 429 |
+
window.clearInterval(poller);
|
| 430 |
+
clearTimeout(t);
|
| 431 |
+
es.close();
|
| 432 |
+
resolve(data.url);
|
| 433 |
+
} else if (data.state === 'fail') {
|
| 434 |
+
settled = true;
|
| 435 |
+
window.clearInterval(poller);
|
| 436 |
+
clearTimeout(t);
|
| 437 |
+
es.close();
|
| 438 |
+
reject(new Error(data.failMsg || 'Seedance generation failed'));
|
| 439 |
+
}
|
| 440 |
+
} catch {
|
| 441 |
+
/* ignore transient poll errors; SSE can still resolve */
|
| 442 |
+
}
|
| 443 |
+
}, pollEveryMs);
|
| 444 |
+
const t = setTimeout(() => {
|
| 445 |
+
if (settled) return;
|
| 446 |
+
settled = true;
|
| 447 |
+
window.clearInterval(poller);
|
| 448 |
+
es.close();
|
| 449 |
+
reject(new Error('Seedance generation timed out'));
|
| 450 |
+
}, timeoutMs);
|
| 451 |
+
es.onmessage = (ev) => {
|
| 452 |
+
if (settled) return;
|
| 453 |
+
try {
|
| 454 |
+
const data = JSON.parse(ev.data) as {
|
| 455 |
+
state?: string;
|
| 456 |
+
url?: string | null;
|
| 457 |
+
failMsg?: string | null;
|
| 458 |
+
};
|
| 459 |
+
if (data.state === 'success' && data.url) {
|
| 460 |
+
settled = true;
|
| 461 |
+
window.clearInterval(poller);
|
| 462 |
+
clearTimeout(t);
|
| 463 |
+
es.close();
|
| 464 |
+
resolve(data.url);
|
| 465 |
+
} else if (data.state === 'fail') {
|
| 466 |
+
settled = true;
|
| 467 |
+
window.clearInterval(poller);
|
| 468 |
+
clearTimeout(t);
|
| 469 |
+
es.close();
|
| 470 |
+
reject(new Error(data.failMsg || 'Seedance generation failed'));
|
| 471 |
+
}
|
| 472 |
+
} catch {
|
| 473 |
+
/* ignore malformed SSE payloads */
|
| 474 |
+
}
|
| 475 |
+
};
|
| 476 |
+
es.onerror = () => {
|
| 477 |
+
/* EventSource retries automatically; timeout handles terminal failure */
|
| 478 |
+
};
|
| 479 |
+
});
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
export async function klingGenerate(body: {
|
| 483 |
+
prompt: string | object;
|
| 484 |
+
imageUrls?: string[];
|
| 485 |
+
model?: string;
|
| 486 |
+
aspectRatio?: string;
|
| 487 |
+
generationType?: string;
|
| 488 |
+
seeds?: number;
|
| 489 |
+
voiceType?: string;
|
| 490 |
+
/** 4, 6, or 8 — forwarded to the API so the provider can honor shot length */
|
| 491 |
+
durationSeconds?: number;
|
| 492 |
+
}): Promise<KlingGenerateResponse> {
|
| 493 |
+
const res = await fetch(`${API_BASE}/api/veo/generate`, {
|
| 494 |
+
method: 'POST',
|
| 495 |
+
headers: { 'Content-Type': 'application/json' },
|
| 496 |
+
body: JSON.stringify(body),
|
| 497 |
+
});
|
| 498 |
+
if (!res.ok) throw new Error(await parseError(res, 'Video start failed'));
|
| 499 |
+
return res.json();
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
export function createKlingEventSource(taskId: string): EventSource {
|
| 503 |
+
return new EventSource(`${API_BASE}/api/veo/events/${taskId}`);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
export function waitForKlingVideo(taskId: string, timeoutMs = 420000): Promise<string> {
|
| 507 |
+
return new Promise((resolve, reject) => {
|
| 508 |
+
const es = createKlingEventSource(taskId);
|
| 509 |
+
const t = setTimeout(() => {
|
| 510 |
+
es.close();
|
| 511 |
+
reject(new Error('Video generation timed out'));
|
| 512 |
+
}, timeoutMs);
|
| 513 |
+
es.onmessage = (ev) => {
|
| 514 |
+
try {
|
| 515 |
+
const data = JSON.parse(ev.data);
|
| 516 |
+
if (data.status === 'succeeded' && data.url) {
|
| 517 |
+
clearTimeout(t);
|
| 518 |
+
es.close();
|
| 519 |
+
resolve(data.url as string);
|
| 520 |
+
} else if (data.status === 'failed' || data.status === 'cancelled') {
|
| 521 |
+
clearTimeout(t);
|
| 522 |
+
es.close();
|
| 523 |
+
reject(new Error(data.error || data.message || 'Generation failed'));
|
| 524 |
+
}
|
| 525 |
+
} catch {
|
| 526 |
+
/* ignore */
|
| 527 |
+
}
|
| 528 |
+
};
|
| 529 |
+
es.onerror = () => {
|
| 530 |
+
/* EventSource retries; rely on timeout */
|
| 531 |
+
};
|
| 532 |
+
});
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
export async function downloadVideo(
|
| 536 |
+
url: string,
|
| 537 |
+
opts?: { trimSeconds?: 4 | 6 | 8 }
|
| 538 |
+
): Promise<Blob> {
|
| 539 |
+
const q = new URLSearchParams({ url });
|
| 540 |
+
if (opts?.trimSeconds != null) q.set('trimSeconds', String(opts.trimSeconds));
|
| 541 |
+
const res = await fetch(`${API_BASE}/api/veo/download?${q}`);
|
| 542 |
+
if (!res.ok) throw new Error('Download failed');
|
| 543 |
+
return res.blob();
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
function loadVideoFromFile(file: File): Promise<HTMLVideoElement> {
|
| 547 |
+
return new Promise((resolve, reject) => {
|
| 548 |
+
const v = document.createElement('video');
|
| 549 |
+
v.preload = 'metadata';
|
| 550 |
+
v.src = URL.createObjectURL(file);
|
| 551 |
+
v.onloadedmetadata = () => resolve(v);
|
| 552 |
+
v.onerror = () => {
|
| 553 |
+
URL.revokeObjectURL(v.src);
|
| 554 |
+
reject(new Error('Could not read video'));
|
| 555 |
+
};
|
| 556 |
+
});
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
export async function getVideoDuration(file: File): Promise<number> {
|
| 560 |
+
const v = await loadVideoFromFile(file);
|
| 561 |
+
try {
|
| 562 |
+
return v.duration;
|
| 563 |
+
} finally {
|
| 564 |
+
URL.revokeObjectURL(v.src);
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
export async function mergeVideos(blobs: Blob[], clips: ClipMetadata[]): Promise<Blob> {
|
| 569 |
+
const fd = new FormData();
|
| 570 |
+
fd.append('clips_data', JSON.stringify(clips));
|
| 571 |
+
blobs.forEach((b, i) => fd.append('files', b, `clip_${i}.mp4`));
|
| 572 |
+
const res = await fetch(`${API_BASE}/api/export/merge`, { method: 'POST', body: fd });
|
| 573 |
+
if (!res.ok) throw new Error(await parseError(res, 'Merge failed'));
|
| 574 |
+
return res.blob();
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/**
|
| 578 |
+
* Run async work on `items` with at most `concurrency` in flight. Results are in original order.
|
| 579 |
+
* Use for I/O-bound pipelines (e.g. several segment renders) without unbounded provider load.
|
| 580 |
+
*/
|
| 581 |
+
export async function mapWithConcurrency<T, R>(
|
| 582 |
+
items: T[],
|
| 583 |
+
concurrency: number,
|
| 584 |
+
mapper: (item: T, index: number) => Promise<R>,
|
| 585 |
+
onProgress?: (completed: number, total: number) => void
|
| 586 |
+
): Promise<R[]> {
|
| 587 |
+
const n = items.length;
|
| 588 |
+
if (n === 0) return [];
|
| 589 |
+
const cap = Math.max(1, Math.min(concurrency, n));
|
| 590 |
+
const results: R[] = new Array(n);
|
| 591 |
+
let next = 0;
|
| 592 |
+
let finished = 0;
|
| 593 |
+
|
| 594 |
+
async function worker(): Promise<void> {
|
| 595 |
+
while (true) {
|
| 596 |
+
const i = next++;
|
| 597 |
+
if (i >= n) return;
|
| 598 |
+
results[i] = await mapper(items[i], i);
|
| 599 |
+
finished++;
|
| 600 |
+
onProgress?.(finished, n);
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
await Promise.all(Array.from({ length: cap }, () => worker()));
|
| 605 |
+
return results;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
/** Default parallel segment pipelines (GPT keyframe + Veo/Seedance). Reduce if providers rate-limit. */
|
| 609 |
+
export const SEGMENT_RENDER_CONCURRENCY = 3;
|
| 610 |
+
|
| 611 |
+
/** Build merge metadata for full-length clips in order. */
|
| 612 |
+
export async function clipsFromBlobs(blobs: Blob[]): Promise<ClipMetadata[]> {
|
| 613 |
+
return Promise.all(
|
| 614 |
+
blobs.map(async (b, i) => {
|
| 615 |
+
const file = new File([b], `c${i}.mp4`, { type: 'video/mp4' });
|
| 616 |
+
const dur = await getVideoDuration(file);
|
| 617 |
+
return {
|
| 618 |
+
index: i,
|
| 619 |
+
startTime: 0,
|
| 620 |
+
endTime: dur,
|
| 621 |
+
type: 'video' as const,
|
| 622 |
+
};
|
| 623 |
+
})
|
| 624 |
+
);
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
export async function generateSegmentFirstFrame(params: {
|
| 628 |
+
segment: VeoSegment;
|
| 629 |
+
referenceImageUrls: string[];
|
| 630 |
+
aspectRatio: string;
|
| 631 |
+
productName: string;
|
| 632 |
+
}): Promise<{ url: string; model: string; size: string }> {
|
| 633 |
+
const res = await fetch(`${API_BASE}/api/showcase/segment-first-frame`, {
|
| 634 |
+
method: 'POST',
|
| 635 |
+
headers: { 'Content-Type': 'application/json' },
|
| 636 |
+
body: JSON.stringify({
|
| 637 |
+
segment: params.segment,
|
| 638 |
+
reference_image_urls: params.referenceImageUrls.slice(0, GPT_IMAGE_EDIT_MAX_REFS),
|
| 639 |
+
aspect_ratio: params.aspectRatio,
|
| 640 |
+
product_name: params.productName,
|
| 641 |
+
}),
|
| 642 |
+
});
|
| 643 |
+
if (!res.ok) throw new Error(await parseError(res, 'GPT Image first frame failed'));
|
| 644 |
+
return res.json();
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
export async function generateSegmentVideo(
|
| 648 |
+
segment: VeoSegment,
|
| 649 |
+
imageUrl: string,
|
| 650 |
+
aspectRatio: string,
|
| 651 |
+
seed: number,
|
| 652 |
+
voiceType: string,
|
| 653 |
+
opts: {
|
| 654 |
+
model?: SegmentVideoModel;
|
| 655 |
+
productName?: string;
|
| 656 |
+
seedanceResolution?: '480p' | '720p' | '1080p';
|
| 657 |
+
promptOverride?: string;
|
| 658 |
+
} = {}
|
| 659 |
+
): Promise<Blob> {
|
| 660 |
+
const clipSec = segmentClipSeconds(segment);
|
| 661 |
+
const model = opts.model ?? 'seedance-2-fast';
|
| 662 |
+
|
| 663 |
+
if (isSeedanceSegmentModel(model)) {
|
| 664 |
+
const prompt =
|
| 665 |
+
opts.promptOverride?.trim() || segmentToSeedancePrompt(segment, opts.productName);
|
| 666 |
+
const { taskId } = await seedanceCreate({
|
| 667 |
+
prompt,
|
| 668 |
+
reference_image_urls: [imageUrl],
|
| 669 |
+
aspect_ratio: aspectRatio,
|
| 670 |
+
duration: clipSec,
|
| 671 |
+
resolution: opts.seedanceResolution ?? '480p',
|
| 672 |
+
generate_audio: voiceType.trim().toLowerCase() !== 'none',
|
| 673 |
+
model: kieSeedanceModelId(model),
|
| 674 |
+
});
|
| 675 |
+
const url = await waitForSeedanceVideo(taskId);
|
| 676 |
+
return downloadVideo(url, { trimSeconds: clipSec });
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
const { taskId } = await klingGenerate({
|
| 680 |
+
prompt: opts.promptOverride?.trim() || segment,
|
| 681 |
+
imageUrls: [imageUrl],
|
| 682 |
+
model: 'veo3_fast',
|
| 683 |
+
aspectRatio,
|
| 684 |
+
generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO',
|
| 685 |
+
seeds: seed,
|
| 686 |
+
voiceType,
|
| 687 |
+
durationSeconds: clipSec,
|
| 688 |
+
});
|
| 689 |
+
const url = await waitForKlingVideo(taskId);
|
| 690 |
+
return downloadVideo(url, { trimSeconds: clipSec });
|
| 691 |
+
}
|
frontend/src/components/ShowcaseFlow.tsx
ADDED
|
@@ -0,0 +1,1468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import type { SegmentsPayload } from '@/types';
|
| 4 |
+
import {
|
| 5 |
+
clipsFromBlobs,
|
| 6 |
+
downloadVideo,
|
| 7 |
+
generateDirectConcepts,
|
| 8 |
+
generateSegmentFirstFrame,
|
| 9 |
+
generateSegmentVideo,
|
| 10 |
+
hostImageFromUrl,
|
| 11 |
+
imageSrcForHeroPreview,
|
| 12 |
+
isSeedanceSegmentModel,
|
| 13 |
+
kieSeedanceModelId,
|
| 14 |
+
mapWithConcurrency,
|
| 15 |
+
mergeVideos,
|
| 16 |
+
pickProductReferenceUrlsForGpt,
|
| 17 |
+
scrapeProductPage,
|
| 18 |
+
SEGMENT_RENDER_CONCURRENCY,
|
| 19 |
+
seedanceCreate,
|
| 20 |
+
segmentToSeedancePrompt,
|
| 21 |
+
showcasePlanStream,
|
| 22 |
+
SHOWCASE_CONCEPT_OPTIONS,
|
| 23 |
+
uploadImage,
|
| 24 |
+
waitForSeedanceVideo,
|
| 25 |
+
type HealthStatus,
|
| 26 |
+
type SegmentVideoModel,
|
| 27 |
+
type ShowcaseConceptId,
|
| 28 |
+
} from '@/api';
|
| 29 |
+
|
| 30 |
+
type Phase = 'brief' | 'planning' | 'review' | 'rendering' | 'done';
|
| 31 |
+
|
| 32 |
+
type ShowcaseFlowProps = {
|
| 33 |
+
health: HealthStatus | null;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
type LibraryVideo = {
|
| 37 |
+
id: string;
|
| 38 |
+
url: string;
|
| 39 |
+
title: string;
|
| 40 |
+
createdAt: number;
|
| 41 |
+
successfulClips: number;
|
| 42 |
+
totalClips: number;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
type ConceptPlan = {
|
| 46 |
+
conceptId: ShowcaseConceptId;
|
| 47 |
+
payload: SegmentsPayload;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
type BriefFlowMode = 'planned' | 'direct_15s';
|
| 51 |
+
|
| 52 |
+
export function ShowcaseFlow({ health }: ShowcaseFlowProps) {
|
| 53 |
+
const [phase, setPhase] = useState<Phase>('brief');
|
| 54 |
+
const [productName, setProductName] = useState('');
|
| 55 |
+
const [tagline, setTagline] = useState('');
|
| 56 |
+
const [mood, setMood] = useState('premium contrast, soft bloom, graphite backdrop');
|
| 57 |
+
const [selectedConcepts, setSelectedConcepts] = useState<ShowcaseConceptId[]>(['luxury_studio']);
|
| 58 |
+
const [features, setFeatures] = useState('');
|
| 59 |
+
const [shotCount, setShotCount] = useState(3);
|
| 60 |
+
const [aspectRatio, setAspectRatio] = useState('9:16');
|
| 61 |
+
const [seed, setSeed] = useState(12005);
|
| 62 |
+
const [voiceType, setVoiceType] = useState('Crisp');
|
| 63 |
+
const [videoModel, setVideoModel] = useState<SegmentVideoModel>('seedance-2-fast');
|
| 64 |
+
const [seedanceResolution, setSeedanceResolution] = useState<'480p' | '720p' | '1080p'>('480p');
|
| 65 |
+
const [imageFile, setImageFile] = useState<File | null>(null);
|
| 66 |
+
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
| 67 |
+
const [hostedUrl, setHostedUrl] = useState<string | null>(null);
|
| 68 |
+
const [productPageUrl, setProductPageUrl] = useState('');
|
| 69 |
+
const [heroRemoteUrl, setHeroRemoteUrl] = useState<string | null>(null);
|
| 70 |
+
const [scrapedImageUrls, setScrapedImageUrls] = useState<string[]>([]);
|
| 71 |
+
const [scrapeBusy, setScrapeBusy] = useState(false);
|
| 72 |
+
/** Synthesize a per-shot keyframe with OpenAI GPT Image (multi-ref edits) before Veo. */
|
| 73 |
+
const [useGptFirstFrames, setUseGptFirstFrames] = useState(true);
|
| 74 |
+
const [renderLabel, setRenderLabel] = useState('');
|
| 75 |
+
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
if (health && !health.openai_configured) {
|
| 78 |
+
setUseGptFirstFrames(false);
|
| 79 |
+
}
|
| 80 |
+
}, [health]);
|
| 81 |
+
|
| 82 |
+
const [planProgress, setPlanProgress] = useState(0);
|
| 83 |
+
const [conceptPlans, setConceptPlans] = useState<ConceptPlan[]>([]);
|
| 84 |
+
const [selectedRenderConcepts, setSelectedRenderConcepts] = useState<ShowcaseConceptId[]>([]);
|
| 85 |
+
const [segmentPromptEdits, setSegmentPromptEdits] = useState<Record<string, string>>({});
|
| 86 |
+
|
| 87 |
+
const [renderIndex, setRenderIndex] = useState(0);
|
| 88 |
+
const [renderTotalSegments, setRenderTotalSegments] = useState(0);
|
| 89 |
+
const [error, setError] = useState<string | null>(null);
|
| 90 |
+
const [finalUrl, setFinalUrl] = useState<string | null>(null);
|
| 91 |
+
const [libraryVideos, setLibraryVideos] = useState<LibraryVideo[]>([]);
|
| 92 |
+
const libraryVideosRef = useRef<LibraryVideo[]>([]);
|
| 93 |
+
const [abort, setAbort] = useState<AbortController | null>(null);
|
| 94 |
+
const [phaseElapsedSeconds, setPhaseElapsedSeconds] = useState(0);
|
| 95 |
+
const [planPulseIndex, setPlanPulseIndex] = useState(0);
|
| 96 |
+
const [renderPulseIndex, setRenderPulseIndex] = useState(0);
|
| 97 |
+
const [briefFlowMode, setBriefFlowMode] = useState<BriefFlowMode>('planned');
|
| 98 |
+
const [directConcepts, setDirectConcepts] = useState<string[]>([]);
|
| 99 |
+
const [selectedDirectConcepts, setSelectedDirectConcepts] = useState<string[]>([]);
|
| 100 |
+
const [conceptsBusy, setConceptsBusy] = useState(false);
|
| 101 |
+
const [selectedReferenceImageUrls, setSelectedReferenceImageUrls] = useState<string[]>([]);
|
| 102 |
+
|
| 103 |
+
const onPickImage = useCallback((f: File | null) => {
|
| 104 |
+
setImageFile(f);
|
| 105 |
+
if (imagePreview && imagePreview.startsWith('blob:')) URL.revokeObjectURL(imagePreview);
|
| 106 |
+
setImagePreview(f ? URL.createObjectURL(f) : null);
|
| 107 |
+
setHostedUrl(null);
|
| 108 |
+
setHeroRemoteUrl(null);
|
| 109 |
+
setScrapedImageUrls([]);
|
| 110 |
+
setSelectedReferenceImageUrls([]);
|
| 111 |
+
}, [imagePreview]);
|
| 112 |
+
|
| 113 |
+
const buildFeaturesFromScrape = (data: Awaited<ReturnType<typeof scrapeProductPage>>) => {
|
| 114 |
+
const parts: string[] = [];
|
| 115 |
+
if (data.description) parts.push(String(data.description).trim());
|
| 116 |
+
if (data.price) parts.push(`Price: ${data.price}`);
|
| 117 |
+
if (data.brand) parts.push(`Brand: ${data.brand}`);
|
| 118 |
+
if (data.category) parts.push(`Category: ${data.category}`);
|
| 119 |
+
if (data.offers) parts.push(`Offers: ${data.offers}`);
|
| 120 |
+
return parts.join('\n\n');
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const importFromProductUrl = async () => {
|
| 124 |
+
setError(null);
|
| 125 |
+
const u = productPageUrl.trim();
|
| 126 |
+
if (!u) {
|
| 127 |
+
setError('Paste a product page URL.');
|
| 128 |
+
return;
|
| 129 |
+
}
|
| 130 |
+
setScrapeBusy(true);
|
| 131 |
+
try {
|
| 132 |
+
const data = await scrapeProductPage(u);
|
| 133 |
+
setProductName((data.product_name || '').trim());
|
| 134 |
+
const desc = (data.description || '').trim();
|
| 135 |
+
setTagline(desc ? desc.slice(0, 120) + (desc.length > 120 ? '…' : '') : '');
|
| 136 |
+
setFeatures(buildFeaturesFromScrape(data));
|
| 137 |
+
const cat = (data.category || '').trim();
|
| 138 |
+
if (cat) {
|
| 139 |
+
setMood((m) => (m.includes(cat) ? m : `${m} · ${cat} editorial`));
|
| 140 |
+
}
|
| 141 |
+
const urls = Array.isArray(data.image_urls) ? data.image_urls.filter(Boolean) : [];
|
| 142 |
+
if (!urls.length) {
|
| 143 |
+
setError('No product images found on this page.');
|
| 144 |
+
return;
|
| 145 |
+
}
|
| 146 |
+
setScrapedImageUrls(urls);
|
| 147 |
+
setSelectedReferenceImageUrls(urls.slice(0, 6));
|
| 148 |
+
setHeroRemoteUrl(urls[0]);
|
| 149 |
+
setImageFile(null);
|
| 150 |
+
const hosted = await hostImageFromUrl(urls[0]);
|
| 151 |
+
setHostedUrl(hosted.url);
|
| 152 |
+
setImagePreview(hosted.url);
|
| 153 |
+
} catch (e) {
|
| 154 |
+
setError(e instanceof Error ? e.message : 'Import failed');
|
| 155 |
+
} finally {
|
| 156 |
+
setScrapeBusy(false);
|
| 157 |
+
}
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const selectScrapedHero = async (url: string) => {
|
| 161 |
+
setError(null);
|
| 162 |
+
try {
|
| 163 |
+
setHeroRemoteUrl(url);
|
| 164 |
+
setSelectedReferenceImageUrls((prev) => (prev.includes(url) ? prev : [...prev, url]));
|
| 165 |
+
setImageFile(null);
|
| 166 |
+
const hosted = await hostImageFromUrl(url);
|
| 167 |
+
setHostedUrl(hosted.url);
|
| 168 |
+
setImagePreview(hosted.url);
|
| 169 |
+
} catch (e) {
|
| 170 |
+
setError(e instanceof Error ? e.message : 'Could not use that image');
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const runPlan = async () => {
|
| 175 |
+
setError(null);
|
| 176 |
+
setConceptPlans([]);
|
| 177 |
+
setSelectedRenderConcepts([]);
|
| 178 |
+
if (!productName.trim()) {
|
| 179 |
+
setError('Add a product name.');
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
if (selectedConcepts.length === 0) {
|
| 183 |
+
setError('Select at least one creative concept.');
|
| 184 |
+
return;
|
| 185 |
+
}
|
| 186 |
+
if (!imageFile && !hostedUrl) {
|
| 187 |
+
setError('Import a product URL or upload a hero image.');
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
setPhase('planning');
|
| 191 |
+
setPlanProgress(0);
|
| 192 |
+
const ac = new AbortController();
|
| 193 |
+
setAbort(ac);
|
| 194 |
+
try {
|
| 195 |
+
let veoHost = hostedUrl;
|
| 196 |
+
if (imageFile) {
|
| 197 |
+
const up = await uploadImage(imageFile);
|
| 198 |
+
veoHost = up.url;
|
| 199 |
+
setHostedUrl(up.url);
|
| 200 |
+
}
|
| 201 |
+
if (!veoHost) {
|
| 202 |
+
setError('Could not resolve a hosted hero image for video generation.');
|
| 203 |
+
setPhase('brief');
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const progressByConcept: Partial<Record<ShowcaseConceptId, number>> = {};
|
| 208 |
+
const conceptOrder = [...selectedConcepts];
|
| 209 |
+
const planned = await mapWithConcurrency(
|
| 210 |
+
conceptOrder,
|
| 211 |
+
Math.min(3, conceptOrder.length),
|
| 212 |
+
async (conceptId) => {
|
| 213 |
+
try {
|
| 214 |
+
const payload: SegmentsPayload = await showcasePlanStream(
|
| 215 |
+
{
|
| 216 |
+
productName: productName.trim(),
|
| 217 |
+
tagline: tagline.trim(),
|
| 218 |
+
mood: mood.trim(),
|
| 219 |
+
features: features.trim(),
|
| 220 |
+
shotCount,
|
| 221 |
+
creativeConcept: conceptId,
|
| 222 |
+
image: imageFile ?? null,
|
| 223 |
+
heroImageUrl: imageFile ? undefined : heroRemoteUrl || undefined,
|
| 224 |
+
},
|
| 225 |
+
(ev) => {
|
| 226 |
+
if (ev.event === 'segment') {
|
| 227 |
+
progressByConcept[conceptId] = ev.progress;
|
| 228 |
+
const sum = conceptOrder.reduce((acc, id) => acc + (progressByConcept[id] ?? 0), 0);
|
| 229 |
+
setPlanProgress(Math.round(sum / conceptOrder.length));
|
| 230 |
+
}
|
| 231 |
+
},
|
| 232 |
+
ac.signal
|
| 233 |
+
);
|
| 234 |
+
return { ok: true as const, conceptId, payload };
|
| 235 |
+
} catch (e) {
|
| 236 |
+
return {
|
| 237 |
+
ok: false as const,
|
| 238 |
+
conceptId,
|
| 239 |
+
error: e instanceof Error ? e.message : 'Planning failed',
|
| 240 |
+
};
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
);
|
| 244 |
+
const successes = planned
|
| 245 |
+
.filter((r) => r.ok)
|
| 246 |
+
.map((r) => ({ conceptId: r.conceptId, payload: r.payload }));
|
| 247 |
+
const failures = planned.filter((r) => !r.ok);
|
| 248 |
+
if (successes.length === 0) {
|
| 249 |
+
throw new Error(failures[0]?.error || 'Planning failed');
|
| 250 |
+
}
|
| 251 |
+
setConceptPlans(successes);
|
| 252 |
+
setSelectedRenderConcepts(successes.map((s) => s.conceptId));
|
| 253 |
+
setSegmentPromptEdits({});
|
| 254 |
+
if (failures.length > 0) {
|
| 255 |
+
setError(`Planned ${successes.length}/${conceptOrder.length} concepts. Some concepts failed.`);
|
| 256 |
+
}
|
| 257 |
+
setPlanProgress(100);
|
| 258 |
+
setPhase('review');
|
| 259 |
+
} catch (e) {
|
| 260 |
+
setError(e instanceof Error ? e.message : 'Planning failed');
|
| 261 |
+
setPhase('brief');
|
| 262 |
+
} finally {
|
| 263 |
+
setAbort(null);
|
| 264 |
+
}
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
const cancelPlan = () => {
|
| 268 |
+
abort?.abort();
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
/** 2–4 distinct URLs for GPT Image edits: hosted hero + storefront hero + scraped gallery (deduped). */
|
| 272 |
+
const referenceUrlsForGpt = useMemo(
|
| 273 |
+
() =>
|
| 274 |
+
pickProductReferenceUrlsForGpt({
|
| 275 |
+
hostedUrl,
|
| 276 |
+
heroRemoteUrl,
|
| 277 |
+
scrapedImageUrls,
|
| 278 |
+
}),
|
| 279 |
+
[hostedUrl, heroRemoteUrl, scrapedImageUrls]
|
| 280 |
+
);
|
| 281 |
+
|
| 282 |
+
const runRender = async () => {
|
| 283 |
+
if (!hostedUrl) {
|
| 284 |
+
setError('Missing hosted image URL. Re-run plan from the brief step.');
|
| 285 |
+
return;
|
| 286 |
+
}
|
| 287 |
+
setError(null);
|
| 288 |
+
setPhase('rendering');
|
| 289 |
+
const plansToRender = conceptPlans.filter((p) => selectedRenderConcepts.includes(p.conceptId));
|
| 290 |
+
if (plansToRender.length === 0) {
|
| 291 |
+
setError('Select at least one concept from the shot plan to render.');
|
| 292 |
+
return;
|
| 293 |
+
}
|
| 294 |
+
const totalSegments = plansToRender.reduce((acc, p) => acc + p.payload.segments.length, 0);
|
| 295 |
+
setRenderIndex(0);
|
| 296 |
+
setRenderTotalSegments(totalSegments);
|
| 297 |
+
setRenderLabel('Starting parallel render…');
|
| 298 |
+
const refs = referenceUrlsForGpt;
|
| 299 |
+
const doGpt = useGptFirstFrames && gptOptionEnabled;
|
| 300 |
+
const modelLabel = isSeedanceSegmentModel(videoModel) ? 'Seedance' : 'Veo';
|
| 301 |
+
try {
|
| 302 |
+
const completedSegmentsByConcept: Partial<Record<ShowcaseConceptId, number>> = {};
|
| 303 |
+
let gptFrameFallbacks = 0;
|
| 304 |
+
const renderedConcepts = await mapWithConcurrency(
|
| 305 |
+
plansToRender,
|
| 306 |
+
Math.min(2, plansToRender.length),
|
| 307 |
+
async ({ conceptId, payload }) => {
|
| 308 |
+
const next = await mapWithConcurrency(
|
| 309 |
+
payload.segments,
|
| 310 |
+
SEGMENT_RENDER_CONCURRENCY,
|
| 311 |
+
async (seg, index) => {
|
| 312 |
+
let veoStillUrl = hostedUrl;
|
| 313 |
+
const promptKey = `${conceptId}:${index}`;
|
| 314 |
+
const promptOverride = segmentPromptEdits[promptKey]?.trim() || undefined;
|
| 315 |
+
try {
|
| 316 |
+
if (doGpt) {
|
| 317 |
+
try {
|
| 318 |
+
const { url } = await generateSegmentFirstFrame({
|
| 319 |
+
segment: seg,
|
| 320 |
+
referenceImageUrls: refs,
|
| 321 |
+
aspectRatio,
|
| 322 |
+
productName: productName.trim(),
|
| 323 |
+
});
|
| 324 |
+
veoStillUrl = url;
|
| 325 |
+
} catch (e) {
|
| 326 |
+
// Fallback: keep rendering with hosted hero image if GPT keyframe fails.
|
| 327 |
+
gptFrameFallbacks += 1;
|
| 328 |
+
console.warn('GPT keyframe failed, using hosted hero image for segment', {
|
| 329 |
+
conceptId,
|
| 330 |
+
segmentIndex: index,
|
| 331 |
+
error: e instanceof Error ? e.message : String(e),
|
| 332 |
+
});
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
const blob = await generateSegmentVideo(
|
| 336 |
+
seg,
|
| 337 |
+
veoStillUrl,
|
| 338 |
+
aspectRatio,
|
| 339 |
+
seed,
|
| 340 |
+
voiceType,
|
| 341 |
+
{
|
| 342 |
+
model: videoModel,
|
| 343 |
+
productName: productName.trim(),
|
| 344 |
+
seedanceResolution,
|
| 345 |
+
promptOverride,
|
| 346 |
+
}
|
| 347 |
+
);
|
| 348 |
+
return { ok: true as const, blob, index };
|
| 349 |
+
} catch (e) {
|
| 350 |
+
return {
|
| 351 |
+
ok: false as const,
|
| 352 |
+
index,
|
| 353 |
+
error: e instanceof Error ? e.message : 'Segment render failed',
|
| 354 |
+
};
|
| 355 |
+
}
|
| 356 |
+
},
|
| 357 |
+
(done, total) => {
|
| 358 |
+
completedSegmentsByConcept[conceptId] = done;
|
| 359 |
+
const doneAll = plansToRender.reduce((acc, p) => acc + (completedSegmentsByConcept[p.conceptId] ?? 0), 0);
|
| 360 |
+
setRenderIndex(doneAll);
|
| 361 |
+
const conceptLabel =
|
| 362 |
+
SHOWCASE_CONCEPT_OPTIONS.find((c) => c.id === conceptId)?.label ?? conceptId;
|
| 363 |
+
setRenderLabel(
|
| 364 |
+
doGpt
|
| 365 |
+
? `GPT keyframes + ${modelLabel}: ${conceptLabel} ${done}/${total} · total ${doneAll}/${totalSegments}`
|
| 366 |
+
: `${modelLabel}: ${conceptLabel} ${done}/${total} · total ${doneAll}/${totalSegments}`
|
| 367 |
+
);
|
| 368 |
+
}
|
| 369 |
+
);
|
| 370 |
+
const successes = next
|
| 371 |
+
.filter((r) => r.ok)
|
| 372 |
+
.sort((a, b) => a.index - b.index)
|
| 373 |
+
.map((r) => r.blob);
|
| 374 |
+
const failures = next.filter((r) => !r.ok);
|
| 375 |
+
if (successes.length === 0) {
|
| 376 |
+
return {
|
| 377 |
+
ok: false as const,
|
| 378 |
+
conceptId,
|
| 379 |
+
error: failures[0]?.error || 'Render failed',
|
| 380 |
+
};
|
| 381 |
+
}
|
| 382 |
+
const meta = await clipsFromBlobs(successes);
|
| 383 |
+
const merged = await mergeVideos(successes, meta);
|
| 384 |
+
return {
|
| 385 |
+
ok: true as const,
|
| 386 |
+
conceptId,
|
| 387 |
+
merged,
|
| 388 |
+
successfulClips: successes.length,
|
| 389 |
+
totalClips: payload.segments.length,
|
| 390 |
+
failedClips: failures.length,
|
| 391 |
+
};
|
| 392 |
+
}
|
| 393 |
+
);
|
| 394 |
+
setRenderLabel('');
|
| 395 |
+
const successes = renderedConcepts.filter((r) => r.ok);
|
| 396 |
+
const failures = renderedConcepts.filter((r) => !r.ok);
|
| 397 |
+
if (successes.length === 0) throw new Error(failures[0]?.error || 'Render failed');
|
| 398 |
+
const createdAt = Date.now();
|
| 399 |
+
const items: LibraryVideo[] = successes.map((s, idx) => {
|
| 400 |
+
const url = URL.createObjectURL(s.merged);
|
| 401 |
+
const conceptLabel = SHOWCASE_CONCEPT_OPTIONS.find((c) => c.id === s.conceptId)?.label ?? s.conceptId;
|
| 402 |
+
return {
|
| 403 |
+
id: `${createdAt}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
| 404 |
+
url,
|
| 405 |
+
title: `${productName.trim() || 'Product showcase'} · ${conceptLabel}`,
|
| 406 |
+
createdAt: createdAt + idx,
|
| 407 |
+
successfulClips: s.successfulClips,
|
| 408 |
+
totalClips: s.totalClips,
|
| 409 |
+
};
|
| 410 |
+
});
|
| 411 |
+
setFinalUrl(items[0]?.url ?? null);
|
| 412 |
+
setLibraryVideos((prev) => [...items, ...prev]);
|
| 413 |
+
const failedConcepts = failures.length;
|
| 414 |
+
const partialClips = successes.reduce((acc, s) => acc + (s.failedClips > 0 ? 1 : 0), 0);
|
| 415 |
+
if (failedConcepts > 0 || partialClips > 0) {
|
| 416 |
+
setError(
|
| 417 |
+
`Rendered ${successes.length}/${plansToRender.length} concepts. ${failedConcepts} concept(s) failed, ${partialClips} concept(s) had partial clip failures.`
|
| 418 |
+
);
|
| 419 |
+
} else if (gptFrameFallbacks > 0) {
|
| 420 |
+
setError(
|
| 421 |
+
`Rendered successfully with ${gptFrameFallbacks} GPT keyframe fallback(s). Some OpenAI first-frame calls failed (502), so those shots used your hosted hero image.`
|
| 422 |
+
);
|
| 423 |
+
}
|
| 424 |
+
setPhase('done');
|
| 425 |
+
} catch (e) {
|
| 426 |
+
setRenderLabel('');
|
| 427 |
+
setError(e instanceof Error ? e.message : 'Render failed');
|
| 428 |
+
setPhase('review');
|
| 429 |
+
}
|
| 430 |
+
};
|
| 431 |
+
|
| 432 |
+
const buildDirectConceptPrompt = (conceptText: string): string => {
|
| 433 |
+
const lines: string[] = [];
|
| 434 |
+
lines.push(`Create a cinematic 15-second product ad video for "${productName.trim()}".`);
|
| 435 |
+
if (tagline.trim()) lines.push(`Tagline direction: ${tagline.trim()}`);
|
| 436 |
+
lines.push(`Creative concept: ${conceptText.trim()}`);
|
| 437 |
+
if (mood.trim()) lines.push(`Mood and grade: ${mood.trim()}.`);
|
| 438 |
+
if (features.trim()) lines.push(`Key product details to preserve: ${features.trim()}`);
|
| 439 |
+
lines.push('Use the reference images as the same product identity across the full clip.');
|
| 440 |
+
lines.push('Photoreal quality, coherent lighting, smooth camera motion, premium commercial finish.');
|
| 441 |
+
return lines.join('\n');
|
| 442 |
+
};
|
| 443 |
+
|
| 444 |
+
const runDirectConceptIdeas = async () => {
|
| 445 |
+
setError(null);
|
| 446 |
+
if (!productName.trim()) {
|
| 447 |
+
setError('Add a product name first, then generate concepts.');
|
| 448 |
+
return;
|
| 449 |
+
}
|
| 450 |
+
setConceptsBusy(true);
|
| 451 |
+
try {
|
| 452 |
+
const { concepts } = await generateDirectConcepts({
|
| 453 |
+
productName: productName.trim(),
|
| 454 |
+
tagline: tagline.trim(),
|
| 455 |
+
mood: mood.trim(),
|
| 456 |
+
features: features.trim(),
|
| 457 |
+
count: 10,
|
| 458 |
+
});
|
| 459 |
+
const unique = Array.from(
|
| 460 |
+
new Set(
|
| 461 |
+
(concepts || [])
|
| 462 |
+
.map((c) => c.trim())
|
| 463 |
+
.filter(Boolean)
|
| 464 |
+
)
|
| 465 |
+
).slice(0, 10);
|
| 466 |
+
if (unique.length === 0) throw new Error('Could not generate concepts. Try again.');
|
| 467 |
+
setDirectConcepts(unique);
|
| 468 |
+
setSelectedDirectConcepts(unique.slice(0, Math.min(3, unique.length)));
|
| 469 |
+
} catch (e) {
|
| 470 |
+
setError(e instanceof Error ? e.message : 'Concept generation failed');
|
| 471 |
+
} finally {
|
| 472 |
+
setConceptsBusy(false);
|
| 473 |
+
}
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
const runDirectConceptRender = async () => {
|
| 477 |
+
setError(null);
|
| 478 |
+
if (!productName.trim()) {
|
| 479 |
+
setError('Add a product name.');
|
| 480 |
+
return;
|
| 481 |
+
}
|
| 482 |
+
if (!isSeedanceSegmentModel(videoModel)) {
|
| 483 |
+
setError('Direct 15s flow is available for Seedance models only.');
|
| 484 |
+
return;
|
| 485 |
+
}
|
| 486 |
+
if (selectedDirectConcepts.length === 0) {
|
| 487 |
+
setError('Generate AI concepts and select at least one concept.');
|
| 488 |
+
return;
|
| 489 |
+
}
|
| 490 |
+
if (!imageFile && !hostedUrl && selectedReferenceImageUrls.length === 0) {
|
| 491 |
+
setError('Import a product URL or upload a hero image.');
|
| 492 |
+
return;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
setPhase('rendering');
|
| 496 |
+
setRenderIndex(0);
|
| 497 |
+
setRenderTotalSegments(selectedDirectConcepts.length);
|
| 498 |
+
setRenderLabel('Generating full 15s concept videos…');
|
| 499 |
+
|
| 500 |
+
try {
|
| 501 |
+
let hosted = hostedUrl;
|
| 502 |
+
if (imageFile) {
|
| 503 |
+
const up = await uploadImage(imageFile);
|
| 504 |
+
hosted = up.url;
|
| 505 |
+
setHostedUrl(up.url);
|
| 506 |
+
setImagePreview(up.url);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
const refs = Array.from(
|
| 510 |
+
new Set(
|
| 511 |
+
[...selectedReferenceImageUrls, heroRemoteUrl || '', hosted || '']
|
| 512 |
+
.map((u) => u.trim())
|
| 513 |
+
.filter(Boolean)
|
| 514 |
+
)
|
| 515 |
+
).slice(0, 9);
|
| 516 |
+
if (refs.length === 0) throw new Error('No valid reference images available for Seedance.');
|
| 517 |
+
|
| 518 |
+
const rendered = await mapWithConcurrency(
|
| 519 |
+
selectedDirectConcepts,
|
| 520 |
+
Math.min(2, selectedDirectConcepts.length),
|
| 521 |
+
async (conceptText) => {
|
| 522 |
+
const { taskId } = await seedanceCreate({
|
| 523 |
+
model: kieSeedanceModelId(videoModel),
|
| 524 |
+
prompt: buildDirectConceptPrompt(conceptText),
|
| 525 |
+
reference_image_urls: refs,
|
| 526 |
+
aspect_ratio: aspectRatio,
|
| 527 |
+
duration: 15,
|
| 528 |
+
resolution: seedanceResolution,
|
| 529 |
+
generate_audio: voiceType.trim().toLowerCase() !== 'none',
|
| 530 |
+
});
|
| 531 |
+
const outUrl = await waitForSeedanceVideo(taskId);
|
| 532 |
+
const blob = await downloadVideo(outUrl);
|
| 533 |
+
return { conceptText, blob };
|
| 534 |
+
},
|
| 535 |
+
(done, total) => {
|
| 536 |
+
setRenderIndex(done);
|
| 537 |
+
setRenderLabel(`Direct 15s concepts: ${done}/${total}`);
|
| 538 |
+
}
|
| 539 |
+
);
|
| 540 |
+
|
| 541 |
+
const createdAt = Date.now();
|
| 542 |
+
const items: LibraryVideo[] = rendered.map((r, idx) => {
|
| 543 |
+
const url = URL.createObjectURL(r.blob);
|
| 544 |
+
return {
|
| 545 |
+
id: `${createdAt}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
| 546 |
+
url,
|
| 547 |
+
title: `${productName.trim() || 'Product showcase'} · ${r.conceptText.slice(0, 64)} · 15s direct`,
|
| 548 |
+
createdAt: createdAt + idx,
|
| 549 |
+
successfulClips: 1,
|
| 550 |
+
totalClips: 1,
|
| 551 |
+
};
|
| 552 |
+
});
|
| 553 |
+
|
| 554 |
+
setRenderLabel('');
|
| 555 |
+
setFinalUrl(items[0]?.url ?? null);
|
| 556 |
+
setLibraryVideos((prev) => [...items, ...prev]);
|
| 557 |
+
setPhase('done');
|
| 558 |
+
} catch (e) {
|
| 559 |
+
setRenderLabel('');
|
| 560 |
+
setError(e instanceof Error ? e.message : 'Direct 15s generation failed');
|
| 561 |
+
setPhase('brief');
|
| 562 |
+
}
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
const reset = () => {
|
| 566 |
+
setFinalUrl(null);
|
| 567 |
+
setConceptPlans([]);
|
| 568 |
+
setSelectedRenderConcepts([]);
|
| 569 |
+
setPhase('brief');
|
| 570 |
+
setRenderIndex(0);
|
| 571 |
+
setRenderTotalSegments(0);
|
| 572 |
+
setError(null);
|
| 573 |
+
setProductPageUrl('');
|
| 574 |
+
setHeroRemoteUrl(null);
|
| 575 |
+
setScrapedImageUrls([]);
|
| 576 |
+
setRenderLabel('');
|
| 577 |
+
setSegmentPromptEdits({});
|
| 578 |
+
setDirectConcepts([]);
|
| 579 |
+
setSelectedDirectConcepts([]);
|
| 580 |
+
setSelectedReferenceImageUrls([]);
|
| 581 |
+
};
|
| 582 |
+
|
| 583 |
+
useEffect(() => {
|
| 584 |
+
libraryVideosRef.current = libraryVideos;
|
| 585 |
+
}, [libraryVideos]);
|
| 586 |
+
|
| 587 |
+
useEffect(() => {
|
| 588 |
+
return () => {
|
| 589 |
+
for (const item of libraryVideosRef.current) {
|
| 590 |
+
URL.revokeObjectURL(item.url);
|
| 591 |
+
}
|
| 592 |
+
};
|
| 593 |
+
}, []);
|
| 594 |
+
|
| 595 |
+
const heroPreviewSrc = useMemo(
|
| 596 |
+
() => imageSrcForHeroPreview(heroRemoteUrl, imagePreview),
|
| 597 |
+
[heroRemoteUrl, imagePreview]
|
| 598 |
+
);
|
| 599 |
+
|
| 600 |
+
const canUseGptFrames = referenceUrlsForGpt.length >= 1;
|
| 601 |
+
const openaiReady = health == null || health.openai_configured;
|
| 602 |
+
const gptOptionEnabled = canUseGptFrames && openaiReady;
|
| 603 |
+
const renderProgressPct = renderTotalSegments ? Math.min(100, (renderIndex / renderTotalSegments) * 100) : 0;
|
| 604 |
+
const planPulseMessages = [
|
| 605 |
+
'Shaping narrative arc and camera rhythm…',
|
| 606 |
+
'Aligning product highlights to visual beats…',
|
| 607 |
+
'Balancing continuity, mood, and pacing…',
|
| 608 |
+
];
|
| 609 |
+
const renderPulseMessages = [
|
| 610 |
+
'Rendering clips in parallel threads…',
|
| 611 |
+
'Maintaining visual continuity between shots…',
|
| 612 |
+
'Preparing final merge and audio sync…',
|
| 613 |
+
];
|
| 614 |
+
const conceptGroups = useMemo(() => {
|
| 615 |
+
const groups: Record<string, Array<(typeof SHOWCASE_CONCEPT_OPTIONS)[number]>> = {};
|
| 616 |
+
for (const concept of SHOWCASE_CONCEPT_OPTIONS) {
|
| 617 |
+
const key = concept.category ?? 'Other';
|
| 618 |
+
if (!groups[key]) groups[key] = [];
|
| 619 |
+
groups[key].push(concept);
|
| 620 |
+
}
|
| 621 |
+
return groups;
|
| 622 |
+
}, []);
|
| 623 |
+
|
| 624 |
+
useEffect(() => {
|
| 625 |
+
if (phase !== 'planning' && phase !== 'rendering') {
|
| 626 |
+
setPhaseElapsedSeconds(0);
|
| 627 |
+
return;
|
| 628 |
+
}
|
| 629 |
+
setPhaseElapsedSeconds(0);
|
| 630 |
+
const interval = window.setInterval(() => {
|
| 631 |
+
setPhaseElapsedSeconds((s) => s + 1);
|
| 632 |
+
}, 1000);
|
| 633 |
+
return () => window.clearInterval(interval);
|
| 634 |
+
}, [phase]);
|
| 635 |
+
|
| 636 |
+
useEffect(() => {
|
| 637 |
+
if (phase !== 'planning') {
|
| 638 |
+
setPlanPulseIndex(0);
|
| 639 |
+
return;
|
| 640 |
+
}
|
| 641 |
+
const interval = window.setInterval(() => {
|
| 642 |
+
setPlanPulseIndex((i) => (i + 1) % planPulseMessages.length);
|
| 643 |
+
}, 2800);
|
| 644 |
+
return () => window.clearInterval(interval);
|
| 645 |
+
}, [phase, planPulseMessages.length]);
|
| 646 |
+
|
| 647 |
+
useEffect(() => {
|
| 648 |
+
if (phase !== 'rendering') {
|
| 649 |
+
setRenderPulseIndex(0);
|
| 650 |
+
return;
|
| 651 |
+
}
|
| 652 |
+
const interval = window.setInterval(() => {
|
| 653 |
+
setRenderPulseIndex((i) => (i + 1) % renderPulseMessages.length);
|
| 654 |
+
}, 2600);
|
| 655 |
+
return () => window.clearInterval(interval);
|
| 656 |
+
}, [phase, renderPulseMessages.length]);
|
| 657 |
+
|
| 658 |
+
return (
|
| 659 |
+
<div className="space-y-10">
|
| 660 |
+
<AnimatePresence mode="wait">
|
| 661 |
+
{phase === 'brief' && (
|
| 662 |
+
<motion.section
|
| 663 |
+
key="brief"
|
| 664 |
+
initial={{ opacity: 0, y: 12 }}
|
| 665 |
+
animate={{ opacity: 1, y: 0 }}
|
| 666 |
+
exit={{ opacity: 0, y: -10 }}
|
| 667 |
+
transition={{ duration: 0.3 }}
|
| 668 |
+
className="studio-card p-6 sm:p-8"
|
| 669 |
+
>
|
| 670 |
+
<div className="studio-step-title">
|
| 671 |
+
<span className="studio-step-badge">1</span>
|
| 672 |
+
<div>
|
| 673 |
+
<h2 className="font-display text-xl font-semibold text-slate-900 sm:text-2xl">Product brief</h2>
|
| 674 |
+
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-600">
|
| 675 |
+
Import a storefront URL to autofill copy and gallery, or type manually. A hosted hero image is
|
| 676 |
+
required for Veo.
|
| 677 |
+
</p>
|
| 678 |
+
</div>
|
| 679 |
+
</div>
|
| 680 |
+
|
| 681 |
+
<div className="studio-import-panel mt-8">
|
| 682 |
+
<span className="studio-label text-accent/80">Import from URL</span>
|
| 683 |
+
<p className="mt-2 text-sm leading-relaxed text-slate-600">
|
| 684 |
+
Scraper pulls JSON-LD, Open Graph, and Shopify{' '}
|
| 685 |
+
<code className="rounded-md bg-slate-200 px-1.5 py-0.5 text-xs text-slate-700">
|
| 686 |
+
/products/<handle>.json
|
| 687 |
+
</code>{' '}
|
| 688 |
+
images.
|
| 689 |
+
</p>
|
| 690 |
+
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-stretch">
|
| 691 |
+
<input
|
| 692 |
+
type="url"
|
| 693 |
+
className="studio-input min-w-0 flex-1 !mt-0"
|
| 694 |
+
value={productPageUrl}
|
| 695 |
+
onChange={(e) => setProductPageUrl(e.target.value)}
|
| 696 |
+
placeholder="https://…/products/your-handle"
|
| 697 |
+
/>
|
| 698 |
+
<button
|
| 699 |
+
type="button"
|
| 700 |
+
disabled={scrapeBusy}
|
| 701 |
+
onClick={importFromProductUrl}
|
| 702 |
+
className="studio-btn-secondary shrink-0 px-6 disabled:opacity-50"
|
| 703 |
+
>
|
| 704 |
+
{scrapeBusy ? 'Importing…' : 'Import product'}
|
| 705 |
+
</button>
|
| 706 |
+
</div>
|
| 707 |
+
{scrapedImageUrls.length > 1 && (
|
| 708 |
+
<div className="mt-5 border-t border-slate-200 pt-5">
|
| 709 |
+
<span className="studio-label">Gallery — tap hero</span>
|
| 710 |
+
<div className="mt-3 flex flex-wrap gap-2.5">
|
| 711 |
+
{scrapedImageUrls.map((u) => (
|
| 712 |
+
<button
|
| 713 |
+
key={u}
|
| 714 |
+
type="button"
|
| 715 |
+
onClick={() => selectScrapedHero(u)}
|
| 716 |
+
className={`relative h-16 w-16 overflow-hidden rounded-xl border-2 transition shadow-md ${
|
| 717 |
+
heroRemoteUrl === u
|
| 718 |
+
? 'border-accent ring-2 ring-accent/30'
|
| 719 |
+
: 'border-slate-200 hover:border-slate-300'
|
| 720 |
+
}`}
|
| 721 |
+
>
|
| 722 |
+
<img src={u} alt="" loading="lazy" className="h-full w-full object-cover" />
|
| 723 |
+
</button>
|
| 724 |
+
))}
|
| 725 |
+
</div>
|
| 726 |
+
</div>
|
| 727 |
+
)}
|
| 728 |
+
{briefFlowMode === 'direct_15s' && scrapedImageUrls.length > 0 && (
|
| 729 |
+
<div className="mt-5 border-t border-slate-200 pt-5">
|
| 730 |
+
<span className="studio-label">Reference images for generation (multi-select)</span>
|
| 731 |
+
<p className="mt-1 text-xs text-slate-600">
|
| 732 |
+
Select multiple product angles so Seedance keeps product identity accurate.
|
| 733 |
+
</p>
|
| 734 |
+
<div className="mt-3 flex flex-wrap gap-2.5">
|
| 735 |
+
{scrapedImageUrls.map((u) => {
|
| 736 |
+
const checked = selectedReferenceImageUrls.includes(u);
|
| 737 |
+
return (
|
| 738 |
+
<button
|
| 739 |
+
key={`ref-${u}`}
|
| 740 |
+
type="button"
|
| 741 |
+
onClick={() =>
|
| 742 |
+
setSelectedReferenceImageUrls((prev) =>
|
| 743 |
+
prev.includes(u) ? prev.filter((x) => x !== u) : [...prev, u]
|
| 744 |
+
)
|
| 745 |
+
}
|
| 746 |
+
className={`relative h-16 w-16 overflow-hidden rounded-xl border-2 transition shadow-md ${
|
| 747 |
+
checked
|
| 748 |
+
? 'border-accent ring-2 ring-accent/30'
|
| 749 |
+
: 'border-slate-200 hover:border-slate-300'
|
| 750 |
+
}`}
|
| 751 |
+
>
|
| 752 |
+
<img src={u} alt="" loading="lazy" className="h-full w-full object-cover" />
|
| 753 |
+
</button>
|
| 754 |
+
);
|
| 755 |
+
})}
|
| 756 |
+
</div>
|
| 757 |
+
<p className="mt-2 text-xs text-slate-500">
|
| 758 |
+
Selected: {selectedReferenceImageUrls.length} / {scrapedImageUrls.length} (up to 9 used)
|
| 759 |
+
</p>
|
| 760 |
+
</div>
|
| 761 |
+
)}
|
| 762 |
+
</div>
|
| 763 |
+
|
| 764 |
+
<div className="mt-8 grid gap-5 sm:grid-cols-2">
|
| 765 |
+
<div className="block sm:col-span-2">
|
| 766 |
+
<span className="studio-label">Generation flow</span>
|
| 767 |
+
<div className="mt-2 flex flex-wrap gap-2">
|
| 768 |
+
<button
|
| 769 |
+
type="button"
|
| 770 |
+
onClick={() => setBriefFlowMode('planned')}
|
| 771 |
+
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
| 772 |
+
briefFlowMode === 'planned'
|
| 773 |
+
? 'border-accent/40 bg-accent/[0.08] text-slate-900'
|
| 774 |
+
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
|
| 775 |
+
}`}
|
| 776 |
+
>
|
| 777 |
+
Plan shots then render
|
| 778 |
+
</button>
|
| 779 |
+
<button
|
| 780 |
+
type="button"
|
| 781 |
+
onClick={() => {
|
| 782 |
+
setBriefFlowMode('direct_15s');
|
| 783 |
+
if (directConcepts.length === 0 && !conceptsBusy) {
|
| 784 |
+
void runDirectConceptIdeas();
|
| 785 |
+
}
|
| 786 |
+
}}
|
| 787 |
+
disabled={!isSeedanceSegmentModel(videoModel)}
|
| 788 |
+
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
| 789 |
+
briefFlowMode === 'direct_15s'
|
| 790 |
+
? 'border-accent/40 bg-accent/[0.08] text-slate-900'
|
| 791 |
+
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
|
| 792 |
+
} disabled:cursor-not-allowed disabled:opacity-50`}
|
| 793 |
+
>
|
| 794 |
+
Direct 15s concepts (Seedance)
|
| 795 |
+
</button>
|
| 796 |
+
</div>
|
| 797 |
+
<p className="mt-2 text-xs text-slate-600">
|
| 798 |
+
{briefFlowMode === 'planned'
|
| 799 |
+
? 'Creates a shot plan first, then renders and merges clips.'
|
| 800 |
+
: 'Skips planning and generates one full 15s video per selected concept from product references.'}
|
| 801 |
+
</p>
|
| 802 |
+
</div>
|
| 803 |
+
{briefFlowMode === 'direct_15s' && (
|
| 804 |
+
<div className="block sm:col-span-2 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 805 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 806 |
+
<span className="studio-label">AI-generated concepts (10)</span>
|
| 807 |
+
<button
|
| 808 |
+
type="button"
|
| 809 |
+
onClick={runDirectConceptIdeas}
|
| 810 |
+
disabled={conceptsBusy}
|
| 811 |
+
className="studio-btn-secondary !px-3 !py-1.5 text-xs disabled:opacity-50"
|
| 812 |
+
>
|
| 813 |
+
{conceptsBusy ? 'Generating…' : 'Regenerate concepts'}
|
| 814 |
+
</button>
|
| 815 |
+
</div>
|
| 816 |
+
<p className="mt-2 text-xs text-slate-600">Select only the concepts you want to render.</p>
|
| 817 |
+
{directConcepts.length > 0 ? (
|
| 818 |
+
<div className="mt-3 space-y-2">
|
| 819 |
+
{directConcepts.map((concept) => {
|
| 820 |
+
const checked = selectedDirectConcepts.includes(concept);
|
| 821 |
+
return (
|
| 822 |
+
<label
|
| 823 |
+
key={concept}
|
| 824 |
+
className={`flex cursor-pointer items-start gap-2 rounded-xl border px-3 py-2 text-sm transition ${
|
| 825 |
+
checked
|
| 826 |
+
? 'border-accent/40 bg-accent/[0.08]'
|
| 827 |
+
: 'border-slate-200 bg-white hover:border-slate-300'
|
| 828 |
+
}`}
|
| 829 |
+
>
|
| 830 |
+
<input
|
| 831 |
+
type="checkbox"
|
| 832 |
+
checked={checked}
|
| 833 |
+
onChange={(e) => {
|
| 834 |
+
setSelectedDirectConcepts((prev) =>
|
| 835 |
+
e.target.checked ? [...prev, concept] : prev.filter((x) => x !== concept)
|
| 836 |
+
);
|
| 837 |
+
}}
|
| 838 |
+
className="mt-0.5 h-4 w-4 rounded border-slate-300 bg-white text-accent focus:ring-accent/40"
|
| 839 |
+
/>
|
| 840 |
+
<span className="leading-snug text-slate-700">{concept}</span>
|
| 841 |
+
</label>
|
| 842 |
+
);
|
| 843 |
+
})}
|
| 844 |
+
</div>
|
| 845 |
+
) : (
|
| 846 |
+
<p className="mt-3 text-xs text-slate-500">
|
| 847 |
+
No concepts yet. Click "Regenerate concepts".
|
| 848 |
+
</p>
|
| 849 |
+
)}
|
| 850 |
+
</div>
|
| 851 |
+
)}
|
| 852 |
+
<label className="block sm:col-span-2">
|
| 853 |
+
<span className="studio-label">Product name</span>
|
| 854 |
+
<input
|
| 855 |
+
className="studio-input !mt-1.5"
|
| 856 |
+
value={productName}
|
| 857 |
+
onChange={(e) => setProductName(e.target.value)}
|
| 858 |
+
placeholder="AuraBrew Six"
|
| 859 |
+
/>
|
| 860 |
+
</label>
|
| 861 |
+
<label className="block sm:col-span-2">
|
| 862 |
+
<span className="studio-label">Tagline / VO line</span>
|
| 863 |
+
<input
|
| 864 |
+
className="studio-input !mt-1.5"
|
| 865 |
+
value={tagline}
|
| 866 |
+
onChange={(e) => setTagline(e.target.value)}
|
| 867 |
+
placeholder="Precision pour. Silent mornings."
|
| 868 |
+
/>
|
| 869 |
+
</label>
|
| 870 |
+
<label className="block sm:col-span-2">
|
| 871 |
+
<span className="studio-label">Mood & grade</span>
|
| 872 |
+
<input
|
| 873 |
+
className="studio-input !mt-1.5"
|
| 874 |
+
value={mood}
|
| 875 |
+
onChange={(e) => setMood(e.target.value)}
|
| 876 |
+
/>
|
| 877 |
+
</label>
|
| 878 |
+
{briefFlowMode === 'planned' && (
|
| 879 |
+
<div className="block sm:col-span-2">
|
| 880 |
+
<span className="studio-label">Creative concepts (select one or more)</span>
|
| 881 |
+
<div className="mt-2 space-y-4">
|
| 882 |
+
{Object.entries(conceptGroups).map(([groupName, concepts]) => (
|
| 883 |
+
<div key={groupName}>
|
| 884 |
+
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{groupName}</p>
|
| 885 |
+
<div className="grid gap-2 sm:grid-cols-2">
|
| 886 |
+
{concepts.map((c) => {
|
| 887 |
+
const checked = selectedConcepts.includes(c.id);
|
| 888 |
+
return (
|
| 889 |
+
<label
|
| 890 |
+
key={c.id}
|
| 891 |
+
className={`cursor-pointer rounded-xl border px-3 py-3 transition ${
|
| 892 |
+
checked
|
| 893 |
+
? 'border-accent/40 bg-accent/[0.08]'
|
| 894 |
+
: 'border-slate-200 bg-white hover:border-slate-300'
|
| 895 |
+
}`}
|
| 896 |
+
>
|
| 897 |
+
<div className="flex items-start gap-2">
|
| 898 |
+
<input
|
| 899 |
+
type="checkbox"
|
| 900 |
+
checked={checked}
|
| 901 |
+
onChange={(e) => {
|
| 902 |
+
setSelectedConcepts((prev) => {
|
| 903 |
+
if (e.target.checked) return [...prev, c.id];
|
| 904 |
+
return prev.filter((id) => id !== c.id);
|
| 905 |
+
});
|
| 906 |
+
}}
|
| 907 |
+
className="mt-0.5 h-4 w-4 rounded border-slate-300 bg-white text-accent focus:ring-accent/40"
|
| 908 |
+
/>
|
| 909 |
+
<div>
|
| 910 |
+
<p className="text-sm font-medium text-slate-900">{c.label}</p>
|
| 911 |
+
<p className="mt-1 text-xs text-slate-600">{c.description}</p>
|
| 912 |
+
</div>
|
| 913 |
+
</div>
|
| 914 |
+
</label>
|
| 915 |
+
);
|
| 916 |
+
})}
|
| 917 |
+
</div>
|
| 918 |
+
</div>
|
| 919 |
+
))}
|
| 920 |
+
</div>
|
| 921 |
+
</div>
|
| 922 |
+
)}
|
| 923 |
+
<label className="block sm:col-span-2">
|
| 924 |
+
<span className="studio-label">Key features (optional)</span>
|
| 925 |
+
<textarea
|
| 926 |
+
className="studio-input !mt-1.5 min-h-[88px] resize-y"
|
| 927 |
+
value={features}
|
| 928 |
+
onChange={(e) => setFeatures(e.target.value)}
|
| 929 |
+
placeholder="Ceramic drip, 92°C thermal lock, magnetic dock…"
|
| 930 |
+
/>
|
| 931 |
+
</label>
|
| 932 |
+
<label className="block">
|
| 933 |
+
<span className="studio-label">Shots (3–6)</span>
|
| 934 |
+
<input
|
| 935 |
+
type="number"
|
| 936 |
+
min={3}
|
| 937 |
+
max={6}
|
| 938 |
+
className="studio-input !mt-1.5"
|
| 939 |
+
value={shotCount}
|
| 940 |
+
onChange={(e) => setShotCount(Number(e.target.value))}
|
| 941 |
+
/>
|
| 942 |
+
</label>
|
| 943 |
+
<div className="block">
|
| 944 |
+
<span className="studio-label">Seconds / shot</span>
|
| 945 |
+
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
|
| 946 |
+
Auto-selected by AI from your concept + product brief (4s, 6s, or 8s pacing).
|
| 947 |
+
</p>
|
| 948 |
+
</div>
|
| 949 |
+
<label className="block">
|
| 950 |
+
<span className="studio-label">Aspect ratio</span>
|
| 951 |
+
<select
|
| 952 |
+
className="studio-select !mt-1.5"
|
| 953 |
+
value={aspectRatio}
|
| 954 |
+
onChange={(e) => setAspectRatio(e.target.value)}
|
| 955 |
+
>
|
| 956 |
+
<option value="9:16">9:16 vertical</option>
|
| 957 |
+
<option value="16:9">16:9 cinematic</option>
|
| 958 |
+
<option value="1:1">1:1 square</option>
|
| 959 |
+
</select>
|
| 960 |
+
</label>
|
| 961 |
+
<label className="block">
|
| 962 |
+
<span className="studio-label">Seed</span>
|
| 963 |
+
<input
|
| 964 |
+
type="number"
|
| 965 |
+
className="studio-input !mt-1.5"
|
| 966 |
+
value={seed}
|
| 967 |
+
onChange={(e) => setSeed(Number(e.target.value))}
|
| 968 |
+
/>
|
| 969 |
+
</label>
|
| 970 |
+
<label className="block sm:col-span-2">
|
| 971 |
+
<span className="studio-label">Video model (KIE · Replicate fallback)</span>
|
| 972 |
+
<select
|
| 973 |
+
className="studio-select !mt-1.5 max-w-md"
|
| 974 |
+
value={videoModel}
|
| 975 |
+
onChange={(e) => setVideoModel(e.target.value as SegmentVideoModel)}
|
| 976 |
+
>
|
| 977 |
+
<option value="veo3_fast">Veo 3.1 Fast (image → video, SSE)</option>
|
| 978 |
+
<option value="seedance-2">Seedance 2 — ByteDance (image + prompt)</option>
|
| 979 |
+
<option value="seedance-2-fast">Seedance 2 Fast — ByteDance (image + prompt)</option>
|
| 980 |
+
</select>
|
| 981 |
+
<p className="mt-1.5 text-xs text-slate-600">
|
| 982 |
+
Same API key. Seedance 2 uses <span className="text-slate-800">first_frame_url</span> image-to-video
|
| 983 |
+
(your hero or GPT keyframe) plus the shot plan as the text prompt; duration 4–15s (planner currently
|
| 984 |
+
picks 4s, 6s, or 8s based on concept + brief). Two URLs would map to first+last frame; three or more
|
| 985 |
+
to reference-only mode per KIE docs.
|
| 986 |
+
</p>
|
| 987 |
+
</label>
|
| 988 |
+
<label className="block sm:col-span-2">
|
| 989 |
+
<span className="studio-label">Seedance resolution</span>
|
| 990 |
+
<select
|
| 991 |
+
className="studio-select !mt-1.5 max-w-md"
|
| 992 |
+
value={seedanceResolution}
|
| 993 |
+
onChange={(e) =>
|
| 994 |
+
setSeedanceResolution(e.target.value as '480p' | '720p' | '1080p')
|
| 995 |
+
}
|
| 996 |
+
disabled={!isSeedanceSegmentModel(videoModel)}
|
| 997 |
+
>
|
| 998 |
+
<option value="480p">480p (default)</option>
|
| 999 |
+
<option value="720p">720p</option>
|
| 1000 |
+
<option value="1080p">1080p</option>
|
| 1001 |
+
</select>
|
| 1002 |
+
<p className="mt-1 text-xs text-slate-500">
|
| 1003 |
+
Applies only when using Seedance models.
|
| 1004 |
+
</p>
|
| 1005 |
+
</label>
|
| 1006 |
+
<label className="block sm:col-span-2">
|
| 1007 |
+
<span className="studio-label">Voice (Veo only)</span>
|
| 1008 |
+
<select
|
| 1009 |
+
className="studio-select !mt-1.5 max-w-md"
|
| 1010 |
+
value={voiceType}
|
| 1011 |
+
onChange={(e) => setVoiceType(e.target.value)}
|
| 1012 |
+
>
|
| 1013 |
+
{['Deep', 'Warm', 'Crisp'].map((v) => (
|
| 1014 |
+
<option key={v} value={v}>
|
| 1015 |
+
{v}
|
| 1016 |
+
</option>
|
| 1017 |
+
))}
|
| 1018 |
+
</select>
|
| 1019 |
+
<p className="mt-1 text-xs text-slate-500">
|
| 1020 |
+
Seedance uses API synced audio (<code className="rounded bg-slate-200 px-1 text-[0.65rem]">generate_audio</code>).
|
| 1021 |
+
</p>
|
| 1022 |
+
</label>
|
| 1023 |
+
<div className="sm:col-span-2">
|
| 1024 |
+
<span className="studio-label">Hero image</span>
|
| 1025 |
+
<p className="mt-1 text-xs text-slate-600">
|
| 1026 |
+
Upload replaces an imported hero. Images are hosted on your API for KIE.
|
| 1027 |
+
</p>
|
| 1028 |
+
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-center">
|
| 1029 |
+
<input
|
| 1030 |
+
type="file"
|
| 1031 |
+
accept="image/*"
|
| 1032 |
+
onChange={(e) => onPickImage(e.target.files?.[0] ?? null)}
|
| 1033 |
+
className="text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-xl file:border-0 file:bg-accent file:px-4 file:py-2.5 file:text-sm file:font-semibold file:text-ink file:shadow-sm hover:file:brightness-110"
|
| 1034 |
+
/>
|
| 1035 |
+
{heroPreviewSrc && (
|
| 1036 |
+
<div className="relative">
|
| 1037 |
+
<img
|
| 1038 |
+
src={heroPreviewSrc}
|
| 1039 |
+
alt="Hero preview"
|
| 1040 |
+
loading="lazy"
|
| 1041 |
+
className="h-28 w-28 rounded-2xl border border-slate-200 object-cover shadow-sm"
|
| 1042 |
+
/>
|
| 1043 |
+
<span className="absolute -bottom-1 -right-1 rounded-full bg-accent px-2 py-0.5 text-[0.6rem] font-bold text-ink">
|
| 1044 |
+
Hero
|
| 1045 |
+
</span>
|
| 1046 |
+
</div>
|
| 1047 |
+
)}
|
| 1048 |
+
</div>
|
| 1049 |
+
</div>
|
| 1050 |
+
</div>
|
| 1051 |
+
<div className="mt-10 flex flex-wrap gap-3 border-t border-slate-200 pt-8">
|
| 1052 |
+
<button
|
| 1053 |
+
type="button"
|
| 1054 |
+
onClick={briefFlowMode === 'direct_15s' ? runDirectConceptRender : runPlan}
|
| 1055 |
+
className="studio-btn-primary"
|
| 1056 |
+
>
|
| 1057 |
+
{briefFlowMode === 'direct_15s'
|
| 1058 |
+
? 'Generate 15s concept videos'
|
| 1059 |
+
: 'Generate shot plan'}
|
| 1060 |
+
</button>
|
| 1061 |
+
</div>
|
| 1062 |
+
</motion.section>
|
| 1063 |
+
)}
|
| 1064 |
+
|
| 1065 |
+
{phase === 'planning' && (
|
| 1066 |
+
<motion.section
|
| 1067 |
+
key="planning"
|
| 1068 |
+
initial={{ opacity: 0, y: 12 }}
|
| 1069 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1070 |
+
exit={{ opacity: 0, y: -10 }}
|
| 1071 |
+
className="studio-card px-6 py-12 text-center sm:px-10 sm:py-14"
|
| 1072 |
+
>
|
| 1073 |
+
<div className="mx-auto max-w-md">
|
| 1074 |
+
<p className="text-xs font-medium uppercase tracking-[0.18em] text-accent/80">
|
| 1075 |
+
AI planner in progress · {phaseElapsedSeconds}s elapsed
|
| 1076 |
+
</p>
|
| 1077 |
+
<div className="studio-progress-track h-2.5">
|
| 1078 |
+
<motion.div
|
| 1079 |
+
className="studio-progress-fill"
|
| 1080 |
+
initial={{ width: 0 }}
|
| 1081 |
+
animate={{ width: `${planProgress}%` }}
|
| 1082 |
+
transition={{ duration: 0.25 }}
|
| 1083 |
+
/>
|
| 1084 |
+
</div>
|
| 1085 |
+
<p className="mt-6 font-display text-lg text-slate-900">Composing your shot plan</p>
|
| 1086 |
+
<p className="mt-2 text-sm text-slate-600">Streaming segment beats from the planner…</p>
|
| 1087 |
+
<AnimatePresence mode="wait">
|
| 1088 |
+
<motion.p
|
| 1089 |
+
key={`plan-pulse-${planPulseIndex}`}
|
| 1090 |
+
initial={{ opacity: 0, y: 4 }}
|
| 1091 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1092 |
+
exit={{ opacity: 0, y: -4 }}
|
| 1093 |
+
transition={{ duration: 0.2 }}
|
| 1094 |
+
className="mt-3 text-xs text-slate-500"
|
| 1095 |
+
>
|
| 1096 |
+
{planPulseMessages[planPulseIndex]}
|
| 1097 |
+
</motion.p>
|
| 1098 |
+
</AnimatePresence>
|
| 1099 |
+
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left">
|
| 1100 |
+
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">What happens next</p>
|
| 1101 |
+
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
|
| 1102 |
+
We will show your generated shot list in a quick review screen before rendering starts.
|
| 1103 |
+
</p>
|
| 1104 |
+
</div>
|
| 1105 |
+
<button type="button" onClick={cancelPlan} className="studio-btn-ghost mt-8">
|
| 1106 |
+
Cancel
|
| 1107 |
+
</button>
|
| 1108 |
+
</div>
|
| 1109 |
+
</motion.section>
|
| 1110 |
+
)}
|
| 1111 |
+
|
| 1112 |
+
{phase === 'review' && (
|
| 1113 |
+
<motion.section
|
| 1114 |
+
key="review"
|
| 1115 |
+
initial={{ opacity: 0, y: 12 }}
|
| 1116 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1117 |
+
exit={{ opacity: 0, y: -10 }}
|
| 1118 |
+
className="studio-card p-6 sm:p-8"
|
| 1119 |
+
>
|
| 1120 |
+
<div className="studio-step-title">
|
| 1121 |
+
<span className="studio-step-badge">2</span>
|
| 1122 |
+
<div>
|
| 1123 |
+
<h2 className="font-display text-xl font-semibold text-slate-900 sm:text-2xl">Shot plans</h2>
|
| 1124 |
+
<p className="mt-1 text-sm text-slate-600">
|
| 1125 |
+
Review each concept plan, then choose one or many concepts to render in parallel.
|
| 1126 |
+
</p>
|
| 1127 |
+
</div>
|
| 1128 |
+
</div>
|
| 1129 |
+
|
| 1130 |
+
<div className="mt-8 space-y-6">
|
| 1131 |
+
{conceptPlans.map(({ conceptId, payload }) => {
|
| 1132 |
+
const conceptMeta = SHOWCASE_CONCEPT_OPTIONS.find((c) => c.id === conceptId);
|
| 1133 |
+
const checked = selectedRenderConcepts.includes(conceptId);
|
| 1134 |
+
return (
|
| 1135 |
+
<section key={conceptId} className="rounded-2xl border border-slate-200 bg-slate-50 p-4 sm:p-5">
|
| 1136 |
+
<label className="flex cursor-pointer items-start gap-3">
|
| 1137 |
+
<input
|
| 1138 |
+
type="checkbox"
|
| 1139 |
+
checked={checked}
|
| 1140 |
+
onChange={(e) => {
|
| 1141 |
+
setSelectedRenderConcepts((prev) => {
|
| 1142 |
+
if (e.target.checked) return [...prev, conceptId];
|
| 1143 |
+
return prev.filter((id) => id !== conceptId);
|
| 1144 |
+
});
|
| 1145 |
+
}}
|
| 1146 |
+
className="mt-1 h-4 w-4 rounded border-slate-300 bg-white text-accent focus:ring-accent/40"
|
| 1147 |
+
/>
|
| 1148 |
+
<div>
|
| 1149 |
+
<p className="text-sm font-semibold text-slate-900">{conceptMeta?.label ?? conceptId}</p>
|
| 1150 |
+
<p className="mt-1 text-xs text-slate-600">
|
| 1151 |
+
{payload.environment || 'Concept plan ready'}
|
| 1152 |
+
{payload.seconds_per_segment ? ` · ${payload.seconds_per_segment}s / shot` : ''}
|
| 1153 |
+
</p>
|
| 1154 |
+
</div>
|
| 1155 |
+
</label>
|
| 1156 |
+
<ol className="mt-4 space-y-2.5">
|
| 1157 |
+
{payload.segments.map((s, i) => {
|
| 1158 |
+
const promptKey = `${conceptId}:${i}`;
|
| 1159 |
+
const generatedPrompt = segmentToSeedancePrompt(s, productName.trim());
|
| 1160 |
+
const currentPrompt = segmentPromptEdits[promptKey] ?? generatedPrompt;
|
| 1161 |
+
const isEdited = currentPrompt.trim() !== generatedPrompt.trim();
|
| 1162 |
+
return (
|
| 1163 |
+
<li key={`${conceptId}-${i + 1}`} className="studio-shot-card pl-5 text-sm text-slate-700">
|
| 1164 |
+
<div className="flex items-center gap-2">
|
| 1165 |
+
<span className="font-mono text-xs font-semibold text-accent">Shot {i + 1}</span>{' '}
|
| 1166 |
+
<span className="text-slate-500">({s.segment_info.duration})</span>
|
| 1167 |
+
{isEdited && (
|
| 1168 |
+
<span className="rounded-full bg-accent/[0.12] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
| 1169 |
+
edited
|
| 1170 |
+
</span>
|
| 1171 |
+
)}
|
| 1172 |
+
</div>
|
| 1173 |
+
<div className="mt-1.5 leading-snug text-slate-600">
|
| 1174 |
+
{s.scene_continuity.camera_movement.slice(0, 96)}
|
| 1175 |
+
</div>
|
| 1176 |
+
<label className="mt-3 block">
|
| 1177 |
+
<span className="studio-label">Prompt (editable)</span>
|
| 1178 |
+
<textarea
|
| 1179 |
+
className="studio-input !mt-1.5 min-h-[110px] resize-y font-mono text-xs leading-relaxed"
|
| 1180 |
+
value={currentPrompt}
|
| 1181 |
+
onChange={(e) =>
|
| 1182 |
+
setSegmentPromptEdits((prev) => ({
|
| 1183 |
+
...prev,
|
| 1184 |
+
[promptKey]: e.target.value,
|
| 1185 |
+
}))
|
| 1186 |
+
}
|
| 1187 |
+
/>
|
| 1188 |
+
</label>
|
| 1189 |
+
{isEdited && (
|
| 1190 |
+
<button
|
| 1191 |
+
type="button"
|
| 1192 |
+
onClick={() =>
|
| 1193 |
+
setSegmentPromptEdits((prev) => {
|
| 1194 |
+
const next = { ...prev };
|
| 1195 |
+
delete next[promptKey];
|
| 1196 |
+
return next;
|
| 1197 |
+
})
|
| 1198 |
+
}
|
| 1199 |
+
className="studio-btn-ghost mt-2 !px-3 !py-1.5 text-xs"
|
| 1200 |
+
>
|
| 1201 |
+
Reset to generated prompt
|
| 1202 |
+
</button>
|
| 1203 |
+
)}
|
| 1204 |
+
</li>
|
| 1205 |
+
);
|
| 1206 |
+
})}
|
| 1207 |
+
</ol>
|
| 1208 |
+
</section>
|
| 1209 |
+
);
|
| 1210 |
+
})}
|
| 1211 |
+
</div>
|
| 1212 |
+
|
| 1213 |
+
{health && health.ffprobe_available === false && (
|
| 1214 |
+
<p className="mt-6 rounded-xl border border-amber-500/25 bg-amber-500/[0.08] px-4 py-3 text-xs leading-relaxed text-amber-100/95">
|
| 1215 |
+
<code className="rounded bg-amber-100 px-1 text-amber-700">ffprobe</code> missing — clip duration
|
| 1216 |
+
for merge may fail. Install FFmpeg on the API host.
|
| 1217 |
+
</p>
|
| 1218 |
+
)}
|
| 1219 |
+
{health && health.ffmpeg_available === false && (
|
| 1220 |
+
<p className="mt-3 rounded-xl border border-red-500/30 bg-red-500/[0.08] px-4 py-3 text-xs leading-relaxed text-red-100">
|
| 1221 |
+
<code className="rounded bg-red-100 px-1 text-red-700">ffmpeg</code> not on server PATH — final
|
| 1222 |
+
merge will error.
|
| 1223 |
+
</p>
|
| 1224 |
+
)}
|
| 1225 |
+
|
| 1226 |
+
<label
|
| 1227 |
+
className={`mt-8 flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition ${
|
| 1228 |
+
gptOptionEnabled
|
| 1229 |
+
? 'border-slate-200 bg-white hover:border-slate-300'
|
| 1230 |
+
: 'cursor-not-allowed border-slate-200 bg-slate-100 opacity-55'
|
| 1231 |
+
}`}
|
| 1232 |
+
>
|
| 1233 |
+
<input
|
| 1234 |
+
type="checkbox"
|
| 1235 |
+
className="mt-1 h-4 w-4 rounded border-slate-300 bg-white text-accent focus:ring-accent/40"
|
| 1236 |
+
checked={useGptFirstFrames && gptOptionEnabled}
|
| 1237 |
+
disabled={!gptOptionEnabled}
|
| 1238 |
+
onChange={(e) => setUseGptFirstFrames(e.target.checked)}
|
| 1239 |
+
/>
|
| 1240 |
+
<div>
|
| 1241 |
+
<span className="text-sm font-semibold text-slate-900">GPT Image keyframes per shot</span>
|
| 1242 |
+
<p className="mt-2 text-xs leading-relaxed text-slate-600">
|
| 1243 |
+
Before each rendered clip, synthesize a first frame with OpenAI{' '}
|
| 1244 |
+
<code className="rounded bg-slate-200 px-1 py-0.5 text-[0.7rem] text-slate-700">images.edit</code>{' '}
|
| 1245 |
+
using{' '}
|
| 1246 |
+
{referenceUrlsForGpt.length} reference image
|
| 1247 |
+
{referenceUrlsForGpt.length === 1 ? '' : 's'}
|
| 1248 |
+
{referenceUrlsForGpt.length >= 2
|
| 1249 |
+
? ' (merged hero + gallery for stronger product match).'
|
| 1250 |
+
: scrapedImageUrls.length > 0
|
| 1251 |
+
? ' from your import.'
|
| 1252 |
+
: heroRemoteUrl
|
| 1253 |
+
? ' (hero URL).'
|
| 1254 |
+
: ' (hosted hero only — import a product page for 2–4 angles).'}{' '}
|
| 1255 |
+
Server needs <code className="rounded bg-slate-200 px-1 text-[0.7rem]">OPENAI_API_KEY</code> and{' '}
|
| 1256 |
+
<code className="rounded bg-slate-200 px-1 text-[0.7rem]">GPT_IMAGE_MODEL</code>
|
| 1257 |
+
{health?.gpt_image_model ? (
|
| 1258 |
+
<>
|
| 1259 |
+
{' '}
|
| 1260 |
+
(
|
| 1261 |
+
<code className="rounded bg-slate-200 px-1 text-[0.7rem] text-accent-dim">
|
| 1262 |
+
{health.gpt_image_model}
|
| 1263 |
+
</code>
|
| 1264 |
+
).
|
| 1265 |
+
</>
|
| 1266 |
+
) : (
|
| 1267 |
+
<>
|
| 1268 |
+
{' '}
|
| 1269 |
+
(e.g. <code className="rounded bg-slate-200 px-1 text-[0.7rem]">gpt-image-1.5</code>).
|
| 1270 |
+
</>
|
| 1271 |
+
)}
|
| 1272 |
+
</p>
|
| 1273 |
+
{health && !health.openai_configured && (
|
| 1274 |
+
<p className="mt-3 text-xs leading-relaxed text-amber-100/90">
|
| 1275 |
+
OpenAI not configured — set <code className="rounded bg-amber-100 px-1 text-amber-800">OPENAI_API_KEY</code> and
|
| 1276 |
+
restart the API.
|
| 1277 |
+
</p>
|
| 1278 |
+
)}
|
| 1279 |
+
</div>
|
| 1280 |
+
</label>
|
| 1281 |
+
|
| 1282 |
+
<div className="mt-10 flex flex-wrap gap-3 border-t border-slate-200 pt-8">
|
| 1283 |
+
<button type="button" onClick={runRender} className="studio-btn-primary">
|
| 1284 |
+
{useGptFirstFrames && gptOptionEnabled
|
| 1285 |
+
? `Generate keyframes + render (${isSeedanceSegmentModel(videoModel) ? 'Seedance' : 'Veo'})`
|
| 1286 |
+
: `Render all segments (${isSeedanceSegmentModel(videoModel) ? 'Seedance' : 'Veo'})`}
|
| 1287 |
+
</button>
|
| 1288 |
+
<button type="button" onClick={() => setPhase('brief')} className="studio-btn-secondary">
|
| 1289 |
+
Edit brief
|
| 1290 |
+
</button>
|
| 1291 |
+
</div>
|
| 1292 |
+
</motion.section>
|
| 1293 |
+
)}
|
| 1294 |
+
|
| 1295 |
+
{phase === 'rendering' && (
|
| 1296 |
+
<motion.section
|
| 1297 |
+
key="rendering"
|
| 1298 |
+
initial={{ opacity: 0, y: 12 }}
|
| 1299 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1300 |
+
exit={{ opacity: 0, y: -10 }}
|
| 1301 |
+
className="studio-card p-6 sm:p-8"
|
| 1302 |
+
>
|
| 1303 |
+
<div className="studio-step-title">
|
| 1304 |
+
<span className="studio-step-badge">3</span>
|
| 1305 |
+
<div>
|
| 1306 |
+
<h2 className="font-display text-xl font-semibold text-slate-900 sm:text-2xl">Rendering</h2>
|
| 1307 |
+
<p className="mt-1 text-sm text-slate-600">
|
| 1308 |
+
{renderLabel || `Segment ${renderIndex} / ${renderTotalSegments}`} — each concept gets its own merged video.
|
| 1309 |
+
</p>
|
| 1310 |
+
</div>
|
| 1311 |
+
</div>
|
| 1312 |
+
<div className="mt-5 flex flex-wrap items-center gap-2 text-xs text-slate-600">
|
| 1313 |
+
<span className="rounded-full border border-slate-300 bg-white px-2.5 py-1">
|
| 1314 |
+
{phaseElapsedSeconds}s elapsed
|
| 1315 |
+
</span>
|
| 1316 |
+
<span className="rounded-full border border-slate-300 bg-white px-2.5 py-1">
|
| 1317 |
+
{Math.round(renderProgressPct)}% complete
|
| 1318 |
+
</span>
|
| 1319 |
+
<span className="rounded-full border border-slate-300 bg-white px-2.5 py-1">
|
| 1320 |
+
{Math.max(0, renderTotalSegments - renderIndex)} clips remaining
|
| 1321 |
+
</span>
|
| 1322 |
+
</div>
|
| 1323 |
+
<div className="studio-progress-track mt-8 h-2.5 max-w-xl">
|
| 1324 |
+
<motion.div
|
| 1325 |
+
className="studio-progress-fill"
|
| 1326 |
+
initial={{ width: 0 }}
|
| 1327 |
+
animate={{
|
| 1328 |
+
width: `${renderProgressPct}%`,
|
| 1329 |
+
}}
|
| 1330 |
+
transition={{ duration: 0.3 }}
|
| 1331 |
+
/>
|
| 1332 |
+
</div>
|
| 1333 |
+
<AnimatePresence mode="wait">
|
| 1334 |
+
<motion.p
|
| 1335 |
+
key={`render-pulse-${renderPulseIndex}`}
|
| 1336 |
+
initial={{ opacity: 0, y: 4 }}
|
| 1337 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1338 |
+
exit={{ opacity: 0, y: -4 }}
|
| 1339 |
+
transition={{ duration: 0.2 }}
|
| 1340 |
+
className="mt-4 text-sm text-slate-600"
|
| 1341 |
+
>
|
| 1342 |
+
{renderPulseMessages[renderPulseIndex]}
|
| 1343 |
+
</motion.p>
|
| 1344 |
+
</AnimatePresence>
|
| 1345 |
+
{renderTotalSegments > 0 && (
|
| 1346 |
+
<div className="mt-6 grid gap-2 sm:grid-cols-2">
|
| 1347 |
+
{Array.from({ length: renderTotalSegments }).map((_, idx) => {
|
| 1348 |
+
const done = idx < renderIndex;
|
| 1349 |
+
return (
|
| 1350 |
+
<div
|
| 1351 |
+
key={`render-shot-${idx + 1}`}
|
| 1352 |
+
className={`rounded-xl border px-3 py-2 text-xs transition ${
|
| 1353 |
+
done
|
| 1354 |
+
? 'border-accent/35 bg-accent/[0.08] text-accent'
|
| 1355 |
+
: 'border-slate-200 bg-white text-slate-500'
|
| 1356 |
+
}`}
|
| 1357 |
+
>
|
| 1358 |
+
Shot {idx + 1} {done ? 'ready' : 'queued'}
|
| 1359 |
+
</div>
|
| 1360 |
+
);
|
| 1361 |
+
})}
|
| 1362 |
+
</div>
|
| 1363 |
+
)}
|
| 1364 |
+
</motion.section>
|
| 1365 |
+
)}
|
| 1366 |
+
|
| 1367 |
+
{phase === 'done' && finalUrl && (
|
| 1368 |
+
<motion.section
|
| 1369 |
+
key="done"
|
| 1370 |
+
initial={{ opacity: 0, y: 12 }}
|
| 1371 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1372 |
+
exit={{ opacity: 0, y: -10 }}
|
| 1373 |
+
className="studio-card p-6 sm:p-8"
|
| 1374 |
+
>
|
| 1375 |
+
<div className="studio-step-title">
|
| 1376 |
+
<span className="studio-step-badge">4</span>
|
| 1377 |
+
<div>
|
| 1378 |
+
<h2 className="font-display text-xl font-semibold text-slate-900 sm:text-2xl">Master cut</h2>
|
| 1379 |
+
<p className="mt-1 text-sm text-slate-600">Stitched with FFmpeg on your API server.</p>
|
| 1380 |
+
</div>
|
| 1381 |
+
</div>
|
| 1382 |
+
<div className="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-md ring-1 ring-slate-200">
|
| 1383 |
+
<video
|
| 1384 |
+
src={finalUrl}
|
| 1385 |
+
controls
|
| 1386 |
+
preload="metadata"
|
| 1387 |
+
className="aspect-video w-full max-h-[min(520px,70vh)] bg-slate-100 object-contain"
|
| 1388 |
+
/>
|
| 1389 |
+
</div>
|
| 1390 |
+
<div className="mt-8 flex flex-wrap gap-3">
|
| 1391 |
+
<a href={finalUrl} download="product-showcase-master.mp4" className="studio-btn-primary">
|
| 1392 |
+
Download MP4
|
| 1393 |
+
</a>
|
| 1394 |
+
<button type="button" onClick={reset} className="studio-btn-secondary">
|
| 1395 |
+
New project
|
| 1396 |
+
</button>
|
| 1397 |
+
</div>
|
| 1398 |
+
</motion.section>
|
| 1399 |
+
)}
|
| 1400 |
+
</AnimatePresence>
|
| 1401 |
+
|
| 1402 |
+
{error && (
|
| 1403 |
+
<div
|
| 1404 |
+
className="flex gap-3 rounded-2xl border border-red-300 bg-red-50 px-4 py-4 text-sm text-red-700 shadow-sm"
|
| 1405 |
+
role="alert"
|
| 1406 |
+
>
|
| 1407 |
+
<span className="shrink-0 text-lg leading-none text-red-500" aria-hidden>
|
| 1408 |
+
!
|
| 1409 |
+
</span>
|
| 1410 |
+
<p className="leading-relaxed">{error}</p>
|
| 1411 |
+
</div>
|
| 1412 |
+
)}
|
| 1413 |
+
|
| 1414 |
+
{libraryVideos.length > 0 && (
|
| 1415 |
+
<section className="studio-card p-6 sm:p-8">
|
| 1416 |
+
<div className="studio-step-title">
|
| 1417 |
+
<span className="studio-step-badge">Library</span>
|
| 1418 |
+
<div>
|
| 1419 |
+
<h2 className="font-display text-xl font-semibold text-slate-900 sm:text-2xl">Generated videos</h2>
|
| 1420 |
+
<p className="mt-1 text-sm text-slate-600">
|
| 1421 |
+
Session library of your master cuts. Click any card to preview it above.
|
| 1422 |
+
</p>
|
| 1423 |
+
</div>
|
| 1424 |
+
</div>
|
| 1425 |
+
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
| 1426 |
+
{libraryVideos.map((item) => {
|
| 1427 |
+
const isActive = finalUrl === item.url;
|
| 1428 |
+
return (
|
| 1429 |
+
<article
|
| 1430 |
+
key={item.id}
|
| 1431 |
+
className={`rounded-2xl border p-3 transition ${
|
| 1432 |
+
isActive
|
| 1433 |
+
? 'border-accent/45 bg-accent/[0.08]'
|
| 1434 |
+
: 'border-slate-200 bg-white hover:border-slate-300'
|
| 1435 |
+
}`}
|
| 1436 |
+
>
|
| 1437 |
+
<button
|
| 1438 |
+
type="button"
|
| 1439 |
+
onClick={() => {
|
| 1440 |
+
setFinalUrl(item.url);
|
| 1441 |
+
setPhase('done');
|
| 1442 |
+
}}
|
| 1443 |
+
className="w-full text-left"
|
| 1444 |
+
>
|
| 1445 |
+
<video src={item.url} preload="metadata" className="aspect-video w-full rounded-xl bg-slate-100 object-cover" />
|
| 1446 |
+
<p className="mt-3 text-sm font-semibold text-slate-900">{item.title}</p>
|
| 1447 |
+
<p className="mt-1 text-xs text-slate-600">
|
| 1448 |
+
{new Date(item.createdAt).toLocaleString()} · {item.successfulClips}/{item.totalClips} clips merged
|
| 1449 |
+
</p>
|
| 1450 |
+
</button>
|
| 1451 |
+
<div className="mt-3 flex gap-2">
|
| 1452 |
+
<a
|
| 1453 |
+
href={item.url}
|
| 1454 |
+
download={`${item.title.toLowerCase().replace(/\s+/g, '-') || 'product-showcase'}-${item.id}.mp4`}
|
| 1455 |
+
className="studio-btn-secondary !px-3 !py-2 text-xs"
|
| 1456 |
+
>
|
| 1457 |
+
Download
|
| 1458 |
+
</a>
|
| 1459 |
+
</div>
|
| 1460 |
+
</article>
|
| 1461 |
+
);
|
| 1462 |
+
})}
|
| 1463 |
+
</div>
|
| 1464 |
+
</section>
|
| 1465 |
+
)}
|
| 1466 |
+
</div>
|
| 1467 |
+
);
|
| 1468 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
html {
|
| 7 |
+
scroll-behavior: smooth;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
body {
|
| 11 |
+
margin: 0;
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
color: #cbd5e1;
|
| 14 |
+
background: #07090e;
|
| 15 |
+
-webkit-font-smoothing: antialiased;
|
| 16 |
+
text-rendering: optimizeLegibility;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
::selection {
|
| 20 |
+
background-color: rgba(212, 175, 55, 0.3);
|
| 21 |
+
color: #f8fafc;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
@layer components {
|
| 26 |
+
.studio-page-bg {
|
| 27 |
+
@apply min-h-screen text-slate-300 antialiased;
|
| 28 |
+
background-color: #07090e;
|
| 29 |
+
background-image: radial-gradient(
|
| 30 |
+
ellipse 85% 65% at 50% -32%,
|
| 31 |
+
rgba(212, 175, 55, 0.11),
|
| 32 |
+
transparent 60%
|
| 33 |
+
),
|
| 34 |
+
radial-gradient(circle at 82% 8%, rgba(148, 163, 184, 0.08), transparent 38%),
|
| 35 |
+
linear-gradient(to bottom, rgba(15, 23, 42, 0.18) 0%, transparent 42%),
|
| 36 |
+
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
| 37 |
+
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
| 38 |
+
background-size: auto, auto, auto, 52px 52px, 52px 52px;
|
| 39 |
+
background-attachment: scroll;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.studio-shell {
|
| 43 |
+
@apply mx-auto w-full max-w-6xl px-4 sm:px-6;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.studio-header {
|
| 47 |
+
@apply sticky top-0 z-20 border-b border-slate-200/80;
|
| 48 |
+
border-color: rgba(148, 163, 184, 0.16);
|
| 49 |
+
background-color: rgba(9, 12, 18, 0.74);
|
| 50 |
+
backdrop-filter: blur(14px);
|
| 51 |
+
box-shadow: 0 1px 0 0 rgba(148, 163, 184, 0.12), 0 20px 35px -34px rgba(0, 0, 0, 0.9);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.studio-card {
|
| 55 |
+
@apply relative overflow-hidden rounded-3xl border bg-white shadow-[0_30px_60px_-42px_rgba(0,0,0,0.95)];
|
| 56 |
+
border-color: rgba(148, 163, 184, 0.2);
|
| 57 |
+
background: linear-gradient(
|
| 58 |
+
145deg,
|
| 59 |
+
rgba(15, 20, 28, 0.94) 0%,
|
| 60 |
+
rgba(17, 24, 39, 0.96) 45%,
|
| 61 |
+
rgba(10, 14, 22, 0.98) 100%
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.studio-card > * {
|
| 66 |
+
@apply relative z-[1];
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.studio-card::before {
|
| 70 |
+
content: '';
|
| 71 |
+
position: absolute;
|
| 72 |
+
inset: 0;
|
| 73 |
+
border-radius: inherit;
|
| 74 |
+
padding: 1px;
|
| 75 |
+
background: linear-gradient(135deg, rgba(212, 175, 55, 0.24), rgba(148, 163, 184, 0.13) 38%, transparent 66%);
|
| 76 |
+
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 77 |
+
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 78 |
+
-webkit-mask-composite: xor;
|
| 79 |
+
mask-composite: exclude;
|
| 80 |
+
pointer-events: none;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.studio-card-inner {
|
| 84 |
+
@apply relative z-[1] p-6 sm:p-8;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.studio-step-title {
|
| 88 |
+
@apply flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.studio-step-badge {
|
| 92 |
+
@apply flex h-10 w-10 shrink-0 items-center justify-center rounded-xl font-display text-sm font-bold tracking-tight text-accent shadow-inner;
|
| 93 |
+
background-color: rgba(212, 175, 55, 0.16);
|
| 94 |
+
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.2), 0 10px 22px -15px rgba(212, 175, 55, 0.8);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.studio-label {
|
| 98 |
+
@apply text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-slate-400;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.studio-input {
|
| 102 |
+
@apply mt-1.5 w-full rounded-xl border px-4 py-2.5 text-sm text-slate-100 transition;
|
| 103 |
+
border-color: rgba(148, 163, 184, 0.22);
|
| 104 |
+
background-color: rgba(15, 23, 42, 0.52);
|
| 105 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
| 106 |
+
@apply placeholder:text-slate-500;
|
| 107 |
+
@apply focus:outline-none focus:ring-2 focus:ring-[rgba(212,175,55,0.18)];
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.studio-input:focus {
|
| 111 |
+
border-color: rgba(212, 175, 55, 0.5);
|
| 112 |
+
box-shadow: 0 0 0 4px rgba(212, 175, 55, 0.14);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.studio-select {
|
| 116 |
+
@apply studio-input cursor-pointer appearance-none;
|
| 117 |
+
background-color: rgba(15, 23, 42, 0.52);
|
| 118 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
| 119 |
+
background-repeat: no-repeat;
|
| 120 |
+
background-position: right 0.75rem center;
|
| 121 |
+
background-size: 1rem;
|
| 122 |
+
padding-right: 2.5rem;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.studio-import-panel {
|
| 126 |
+
@apply relative mt-6 rounded-2xl border p-5 sm:p-6;
|
| 127 |
+
border-color: rgba(212, 175, 55, 0.22);
|
| 128 |
+
background: linear-gradient(to bottom right, rgba(212, 175, 55, 0.1), rgba(15, 23, 42, 0.44) 62%);
|
| 129 |
+
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.08), 0 15px 30px -24px rgba(0, 0, 0, 0.85),
|
| 130 |
+
inset 0 1px 0 0 rgba(255, 255, 255, 0.06);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.studio-btn-primary {
|
| 134 |
+
@apply inline-flex items-center justify-center rounded-xl bg-accent px-6 py-3 text-sm font-semibold text-ink shadow-glow transition;
|
| 135 |
+
background-image: linear-gradient(160deg, #e5c96b, #d4af37 58%, #c49a1b);
|
| 136 |
+
@apply hover:brightness-110 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-45;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.studio-btn-secondary {
|
| 140 |
+
@apply inline-flex items-center justify-center rounded-xl border px-5 py-2.5 text-sm font-medium text-slate-100 transition;
|
| 141 |
+
border-color: rgba(148, 163, 184, 0.24);
|
| 142 |
+
background-color: rgba(15, 23, 42, 0.55);
|
| 143 |
+
@apply hover:border-slate-400 hover:bg-slate-800/80 active:scale-[0.98] disabled:opacity-45;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.studio-btn-ghost {
|
| 147 |
+
@apply text-xs font-medium text-slate-400 underline decoration-slate-500 underline-offset-4 transition hover:text-slate-200;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.studio-pill {
|
| 151 |
+
@apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[0.7rem] font-medium tracking-wide shadow-sm;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.studio-progress-track {
|
| 155 |
+
@apply h-2 overflow-hidden rounded-full bg-slate-300/70 shadow-inner shadow-slate-400/40;
|
| 156 |
+
border: 1px solid rgba(148, 163, 184, 0.2);
|
| 157 |
+
background-color: rgba(30, 41, 59, 0.75);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.studio-progress-fill {
|
| 161 |
+
@apply h-full rounded-full bg-gradient-to-r from-accent-dim to-accent shadow-sm;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.studio-shot-card {
|
| 165 |
+
@apply relative rounded-xl border px-4 py-3.5 transition hover:border-slate-400;
|
| 166 |
+
border-color: rgba(148, 163, 184, 0.18);
|
| 167 |
+
background: rgba(15, 23, 42, 0.42);
|
| 168 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.studio-shot-card::before {
|
| 172 |
+
content: '';
|
| 173 |
+
@apply absolute left-0 top-3 bottom-3 w-0.5 rounded-full;
|
| 174 |
+
background-color: rgba(212, 175, 55, 0.4);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* Dark-theme compatibility layer for existing utility classes inside studio surfaces. */
|
| 178 |
+
.studio-page-bg .text-slate-900 {
|
| 179 |
+
color: #f1f5f9 !important;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.studio-page-bg .text-slate-800 {
|
| 183 |
+
color: #e2e8f0 !important;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.studio-page-bg .text-slate-700,
|
| 187 |
+
.studio-page-bg .text-slate-600 {
|
| 188 |
+
color: #94a3b8 !important;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.studio-page-bg .text-slate-500 {
|
| 192 |
+
color: #64748b !important;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.studio-page-bg .border-slate-200,
|
| 196 |
+
.studio-page-bg .border-slate-300,
|
| 197 |
+
.studio-page-bg .border-slate-400\/80 {
|
| 198 |
+
border-color: rgba(148, 163, 184, 0.22) !important;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.studio-page-bg .bg-white,
|
| 202 |
+
.studio-page-bg .bg-slate-50,
|
| 203 |
+
.studio-page-bg .bg-slate-100,
|
| 204 |
+
.studio-page-bg .bg-slate-100\/70 {
|
| 205 |
+
background-color: rgba(15, 23, 42, 0.48) !important;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.studio-page-bg code {
|
| 209 |
+
background-color: rgba(30, 41, 59, 0.7) !important;
|
| 210 |
+
color: #e2e8f0 !important;
|
| 211 |
+
}
|
| 212 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react';
|
| 2 |
+
import { createRoot } from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>
|
| 10 |
+
);
|
frontend/src/types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface ContinuityMarkers {
|
| 2 |
+
start_position: string;
|
| 3 |
+
end_position: string;
|
| 4 |
+
start_expression: string;
|
| 5 |
+
end_expression: string;
|
| 6 |
+
start_gesture: string;
|
| 7 |
+
end_gesture: string;
|
| 8 |
+
location_status: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface SegmentInfo {
|
| 12 |
+
segment_number: number;
|
| 13 |
+
total_segments: number;
|
| 14 |
+
duration: string;
|
| 15 |
+
location: string;
|
| 16 |
+
continuity_markers: ContinuityMarkers;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export interface CharacterDescription {
|
| 20 |
+
current_state: string;
|
| 21 |
+
voice_matching: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export type SynchronizedActions = Record<string, string>;
|
| 25 |
+
|
| 26 |
+
export interface ActionTimeline {
|
| 27 |
+
dialogue: string;
|
| 28 |
+
synchronized_actions: SynchronizedActions;
|
| 29 |
+
micro_expressions: string;
|
| 30 |
+
breathing_rhythm: string;
|
| 31 |
+
location_transition: string;
|
| 32 |
+
continuity_checkpoint: string;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export interface SceneContinuity {
|
| 36 |
+
environment: string;
|
| 37 |
+
camera_position: string;
|
| 38 |
+
camera_movement: string;
|
| 39 |
+
lighting_state: string;
|
| 40 |
+
background_elements: string;
|
| 41 |
+
spatial_relationships: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export interface VeoSegment {
|
| 45 |
+
segment_info: SegmentInfo;
|
| 46 |
+
character_description: CharacterDescription;
|
| 47 |
+
scene_continuity: SceneContinuity;
|
| 48 |
+
action_timeline: ActionTimeline;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export interface SegmentsPayload {
|
| 52 |
+
segments: VeoSegment[];
|
| 53 |
+
environment?: string;
|
| 54 |
+
/** Backend concept id, e.g. luxury_studio, ugc_authentic */
|
| 55 |
+
creative_concept?: string;
|
| 56 |
+
/** AI-selected segment pacing in seconds (4/6/8). */
|
| 57 |
+
seconds_per_segment?: 4 | 6 | 8;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export type StreamEvent =
|
| 61 |
+
| { event: 'start'; total_segments: number; model: string }
|
| 62 |
+
| {
|
| 63 |
+
event: 'segment';
|
| 64 |
+
index: number;
|
| 65 |
+
total: number;
|
| 66 |
+
progress: number;
|
| 67 |
+
segment: VeoSegment;
|
| 68 |
+
}
|
| 69 |
+
| { event: 'complete'; message: string; prompt_id: string; payload: SegmentsPayload }
|
| 70 |
+
| { event: 'error'; message: string; error_type?: string };
|
| 71 |
+
|
| 72 |
+
export interface ClipMetadata {
|
| 73 |
+
index: number;
|
| 74 |
+
startTime: number;
|
| 75 |
+
endTime: number;
|
| 76 |
+
type: 'video' | 'image';
|
| 77 |
+
duration?: number;
|
| 78 |
+
}
|
frontend/src/vite-env.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
| 2 |
+
|
| 3 |
+
interface ImportMetaEnv {
|
| 4 |
+
readonly VITE_API_BASE_URL?: string;
|
| 5 |
+
/** When "true" in dev, use VITE_API_BASE_URL in the browser (cross-origin; needs CORS on API). */
|
| 6 |
+
readonly VITE_PUBLIC_API_IN_DEV?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface ImportMeta {
|
| 10 |
+
readonly env: ImportMetaEnv;
|
| 11 |
+
}
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
| 4 |
+
theme: {
|
| 5 |
+
extend: {
|
| 6 |
+
fontFamily: {
|
| 7 |
+
sans: ['Satoshi', 'system-ui', 'sans-serif'],
|
| 8 |
+
display: ['"Clash Display"', 'Satoshi', 'system-ui', 'sans-serif'],
|
| 9 |
+
},
|
| 10 |
+
colors: {
|
| 11 |
+
ink: '#070708',
|
| 12 |
+
ink2: '#0e0e12',
|
| 13 |
+
mist: '#e8e6e3',
|
| 14 |
+
accent: '#d4af37',
|
| 15 |
+
'accent-dim': '#9a7b2c',
|
| 16 |
+
surface: '#121218',
|
| 17 |
+
line: 'rgba(255,255,255,0.07)',
|
| 18 |
+
},
|
| 19 |
+
boxShadow: {
|
| 20 |
+
card: '0 0 0 1px rgba(255,255,255,0.06), 0 24px 48px -16px rgba(0,0,0,0.65)',
|
| 21 |
+
'card-hover': '0 0 0 1px rgba(212,175,55,0.12), 0 28px 56px -16px rgba(0,0,0,0.7)',
|
| 22 |
+
glow: '0 0 40px -8px rgba(212,175,55,0.25)',
|
| 23 |
+
},
|
| 24 |
+
backgroundImage: {
|
| 25 |
+
'radial-header':
|
| 26 |
+
'radial-gradient(ellipse 80% 60% at 50% -30%, rgba(212,175,55,0.12), transparent 55%)',
|
| 27 |
+
'grid-fade':
|
| 28 |
+
'linear-gradient(to bottom, rgba(7,7,8,0.3) 0%, transparent 40%), linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)',
|
| 29 |
+
},
|
| 30 |
+
backgroundSize: {
|
| 31 |
+
grid: '48px 48px',
|
| 32 |
+
},
|
| 33 |
+
animation: {
|
| 34 |
+
shimmer: 'shimmer 2s ease-in-out infinite',
|
| 35 |
+
},
|
| 36 |
+
keyframes: {
|
| 37 |
+
shimmer: {
|
| 38 |
+
'0%, 100%': { opacity: '0.45' },
|
| 39 |
+
'50%': { opacity: '0.9' },
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
},
|
| 44 |
+
plugins: [],
|
| 45 |
+
};
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"isolatedModules": true,
|
| 11 |
+
"moduleDetection": "force",
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUnusedLocals": true,
|
| 16 |
+
"noUnusedParameters": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true,
|
| 18 |
+
"noUncheckedSideEffectImports": true,
|
| 19 |
+
"baseUrl": ".",
|
| 20 |
+
"paths": { "@/*": ["src/*"] }
|
| 21 |
+
},
|
| 22 |
+
"include": ["src", "vite.config.ts"]
|
| 23 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, loadEnv } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
export default defineConfig(({ mode }) => {
|
| 6 |
+
const repoRoot = path.resolve(__dirname, '..');
|
| 7 |
+
const env = loadEnv(mode, repoRoot, '');
|
| 8 |
+
|
| 9 |
+
const proxyTarget =
|
| 10 |
+
env.VITE_DEV_PROXY_TARGET || env.VITE_PROXY_API || 'http://127.0.0.1:4010';
|
| 11 |
+
const isNgrok = proxyTarget.includes('ngrok');
|
| 12 |
+
|
| 13 |
+
const proxyConfig = {
|
| 14 |
+
target: proxyTarget,
|
| 15 |
+
changeOrigin: true,
|
| 16 |
+
configure(proxy: { on: (e: string, fn: (req: unknown, res: unknown) => void) => void }) {
|
| 17 |
+
if (!isNgrok) return;
|
| 18 |
+
proxy.on('proxyReq', (proxyReq: { setHeader: (k: string, v: string) => void }) => {
|
| 19 |
+
proxyReq.setHeader('ngrok-skip-browser-warning', '69420');
|
| 20 |
+
});
|
| 21 |
+
},
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return {
|
| 25 |
+
envDir: repoRoot,
|
| 26 |
+
plugins: [react()],
|
| 27 |
+
resolve: {
|
| 28 |
+
alias: { '@': path.resolve(__dirname, './src') },
|
| 29 |
+
},
|
| 30 |
+
server: {
|
| 31 |
+
port: 5173,
|
| 32 |
+
proxy: {
|
| 33 |
+
'/api': proxyConfig,
|
| 34 |
+
'/health': proxyConfig,
|
| 35 |
+
},
|
| 36 |
+
},
|
| 37 |
+
};
|
| 38 |
+
});
|
scripts/run-dev.sh
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 4 |
+
export PYTHONPATH="${ROOT}/backend:${PYTHONPATH:-}"
|
| 5 |
+
|
| 6 |
+
echo "API: cd ${ROOT}/backend && python main.py"
|
| 7 |
+
echo "UI: cd ${ROOT}/frontend && npm run dev"
|
| 8 |
+
echo "Vite loads env from ${ROOT} (repo root)."
|