sushilideaclan01 commited on
Commit
2f9de1b
·
1 Parent(s): fae74e0
.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/&lt;handle&gt;.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 &quot;Regenerate concepts&quot;.
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)."