diff --git a/.github/workflows/schema-drift.yml b/.github/workflows/schema-drift.yml
new file mode 100644
index 00000000..5663a218
--- /dev/null
+++ b/.github/workflows/schema-drift.yml
@@ -0,0 +1,73 @@
+# Schema drift PR gate (#373 v2)
+# TS 상수 ↔ DB CHECK constraint 정합성을 PR 단계에서 검증
+# 자세한 운영 가이드: docs/database/schema-drift-sot.md
+name: Schema drift gate
+
+on:
+ pull_request:
+ paths:
+ - "supabase/migrations/**"
+ - "packages/api-server/migration/**"
+ - "packages/web/lib/api/admin/**.ts"
+ - "scripts/check-schema-drift.ts"
+ - ".github/workflows/schema-drift.yml"
+ - "docs/database/schema-drift-sot.md"
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ drift:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_PASSWORD: testpass
+ POSTGRES_DB: drift
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ env:
+ LOCAL_DATABASE_URL: postgresql://postgres:testpass@localhost:5432/drift
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: "1.3.10"
+
+ - name: Install postgresql-client 17
+ run: |
+ set -euo pipefail
+ sudo apt-get update -qq
+ sudo apt-get install -y curl ca-certificates lsb-release gnupg
+ curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
+ | sudo gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
+ echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
+ | sudo tee /etc/apt/sources.list.d/pgdg.list
+ sudo apt-get update -qq
+ sudo apt-get install -y postgresql-client-17
+ psql --version
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Run schema drift check
+ id: drift
+ # drift-bypass label → step failure를 무시하여 job green 유지
+ # 단, drift report 는 항상 PR comment 로 게시
+ continue-on-error: ${{ contains(github.event.pull_request.labels.*.name, 'drift-bypass') }}
+ run: bun run scripts/check-schema-drift.ts | tee /tmp/drift.md
+
+ - name: Comment PR with drift report
+ if: always() && hashFiles('/tmp/drift.md') != ''
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: schema-drift
+ path: /tmp/drift.md
diff --git a/.gitignore b/.gitignore
index bd281c2b..80d16751 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,7 @@ packages/web/.playwright/
# Local dev log files (optional tail -f workflows)
.logs/
+packages/web/.local/
# Orval generated API client (regenerated from OpenAPI spec)
packages/web/lib/api/generated/
diff --git a/bun.lock b/bun.lock
index 9b1bdf95..e00021ab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@types/react-dom": "^19.2.0",
"ajv": "^8.18.0",
"gray-matter": "^4.0.3",
+ "ts-morph": "^24.0.0",
"turbo": "^2.9.3",
},
},
@@ -1051,7 +1052,7 @@
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
- "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
+ "@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg=="],
@@ -2983,7 +2984,7 @@
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
- "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
+ "ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -3359,7 +3360,7 @@
"@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
- "@ts-morph/common/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+ "@ts-morph/common/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -3595,6 +3596,8 @@
"shadcn/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+ "shadcn/ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
+
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
@@ -3765,7 +3768,7 @@
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
- "@ts-morph/common/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+ "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
@@ -3907,6 +3910,8 @@
"shadcn/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+ "shadcn/ts-morph/@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
+
"tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -3967,6 +3972,8 @@
"@sentry/cli/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+ "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
"cross-fetch/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"cross-fetch/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
diff --git a/docs/agent/api-v1-routes.md b/docs/agent/api-v1-routes.md
index d9ed10db..59fb336f 100644
--- a/docs/agent/api-v1-routes.md
+++ b/docs/agent/api-v1-routes.md
@@ -38,6 +38,12 @@ Params: `q`, `context`, `media_type`, `sort`, `page`, `limit`.
| `/api/v1/tries` | POST | Current user's VTON result save |
| `/api/v1/post-magazines/[id]` | GET | Post magazine data |
| `/api/v1/post-magazines/generate` | POST | Trigger editorial generation for a post (admin only, proxy → Rust) |
+| `/api/v1/content/assets/videos` | POST | Content Studio short-form scene video generation (admin only, xAI → Supabase Storage) |
+| `/api/v1/content/assets/video-jobs` | GET/POST | List recent persisted xAI short-form video jobs by `packetId`; create an admin-only job and persist scene request IDs. |
+| `/api/v1/content/assets/video-jobs/[jobId]` | GET | Load a persisted short-form video job for resume. |
+| `/api/v1/content/assets/video-jobs/[jobId]/poll` | POST | Poll running xAI scenes once and persist completed Storage URLs or failures. |
+| `/api/v1/content/assets/video-jobs/[jobId]/compose` | POST | Compose completed scene clips into one final 9:16 ffmpeg export, with optional voiceover/BGM URLs. |
+| `/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry` | POST | Restart one failed or expired xAI scene. |
## Solutions & spots
diff --git a/docs/agent/design-system-llm.md b/docs/agent/design-system-llm.md
index 6083fca7..c3728bd6 100644
--- a/docs/agent/design-system-llm.md
+++ b/docs/agent/design-system-llm.md
@@ -2,17 +2,82 @@
title: Design System — LLM Reference
owner: human
status: approved
-updated: 2026-04-17
+updated: 2026-05-28
tags: [agent, design-system]
---
-# Design System — LLM Reference
+# Design System — LLM Reference (v2.2.0)
-Implementation: `packages/web/lib/design-system/`. Human-facing 토큰·패턴 상세는 [docs/design-system/](../design-system/)를 SSOT로 둡니다.
+
-## Import path
+decoded Design System v2.2 1-pager SSOT. Human-facing 토큰·패턴 상세는 [docs/design-system/](../design-system/)를 SSOT로 둡니다.
+구현체: `packages/web/lib/design-system/` + `packages/web/app/globals.css`.
+
+## Visual Foundations
+
+### Dual palette (surface-scoped)
+
+- **Editorial surfaces** (`/`, `/magazine/*`, spot detail) → magazine palette, **fixed dark**, `.dark` 토글 무관.
+ - Tokens: `magPrimary` (`--mag-primary` `#050505`), `magAccent` (`--mag-accent` `#eafd67`), `magBg` (`--mag-bg` `#050505`), `magText` (`--mag-text` `#f5f5f5`)
+ - Source: `packages/web/lib/design-system/tokens.ts:176-179`, `packages/web/app/globals.css:109-112` (#573)
+ - ThemeToggle 숨김
+- **Product UI** (`/explore`, `/profile`, `/rankings`, `/admin`, `/auth`, `/upload`, `/settings`) → semantic OKLCH, `next-themes` `.dark` 토글 활성.
+ - Tokens: `primary`, `secondary`, `muted`, `accent`, `destructive`, `background`, `foreground`, `card`, `border`, `ring`
+- **No-mix rule**: 두 팔레트를 한 페이지에서 섞지 말 것. 섞여야 한다면 surface 경계가 잘못 그어진 것.
+
+### Typography
+
+- Playfair Display (serif) — 제목/브랜드/Hero
+- Inter (sans) — 본문/UI/버튼
+- JetBrains Mono (mono) — 코드
+- **IBM Plex Mono** — DecodedLogo / HeroCover inline 사용 (현재 `next/font` 미적용, 점진 마이그레이션 대상)
+
+크기 스케일: `hero(64px) / h1(48) / h2(36) / h3(28) / h4(24) / body(16) / small(14) / caption(12)`.
+
+### Spacing / Radius / Shadow
+
+4px 기반 spacing (`0` ~ `32`), `--radius` 기준 borderRadius (`sm` ~ `2xl`, `full`), CSS-var 기반 shadow (`2xs` ~ `2xl`).
+
+### Z-index layers
+
+`base(0) / floating(10) / dropdown(20) / header(30) / sidebar(40) / modalBackdrop(50) / modal(60) / toast(70) / tooltip(100)`.
+
+### Navigation facts
+
+| Surface | Height | Source |
+| ---------------- | ------------------------ | ------------------------------------------------ |
+| Desktop Header | 72px | `lib/design-system/desktop-header.tsx` |
+| Mobile Header | 56px | `lib/design-system/mobile-header.tsx` |
+| 본문 top padding | `pt-[56px] md:pt-[72px]` | [patterns.md §1.4](../design-system/patterns.md) |
+
+### Home page sequence
+
+`packages/web/app/page.tsx:568` 기준:
+`HeroItemSync` (md+) → `EditorialMagazine` → `EditorialCarousel` → `StyleMoods` → `EditorPicks` (data 있을 때) → `TrendingListSection` → `DomeGallerySection` (data 있을 때).
+루트 컨테이너: `
` — magazine palette 고정.
+
+### 3D / Shader
+
+`DecodedLogo` → three.js + `@chenglou/pretext` shader, `packages/web/lib/components/DecodedLogo.tsx`.
-All design system components are exported from a single barrel import:
+## Content Fundamentals
+
+decoded 콘텐츠 규칙 (v2.2 신규, 정본 [content-fundamentals.md](../design-system/content-fundamentals.md)):
+
+- **Voice**: 큐레이터 톤 — 단정적·간결·과한 마케팅 언어 회피
+- **Casing**: 영문 헤더는 Title Case, 본문은 Sentence case
+- **Person**: 사용자 1인칭("나의 ...") vs 시스템 3인칭 명확히 구분
+- **Language**: 한국어 우선, 영문 브랜드/기술 용어는 원형 보존
+- **Pricing**: 통화 기호 + 천 단위 콤마, 통화 코드는 후위 (예: `₩48,000 KRW`)
+- **Numbers**: 천 단위 콤마, 0.xxx 단축 금지
+- **Emoji**: UI 안에 emoji 사용 금지 (decorative 포함). 본문/에디토리얼만 허용.
+
+## Iconography
+
+Lucide Icons SSOT. 도메인 특화 아이콘은 `lib/design-system/` 내 컴포넌트로 캡슐화.
+크기 규칙: inline body 16px, button leading icon 16-20px, hero/feature 24-32px. Animation 카탈로그는 [animations.md](../design-system/animations.md) 참조 (`spin`, `pulse`, `fade-in-up`, `marquee` 등 15+ keyframes + `prefers-reduced-motion` 정책).
+
+## Import path
```typescript
import {
@@ -32,7 +97,7 @@ import {
GridCard,
FeedCardBase,
ProfileHeaderCard,
- // Headers & Footer
+ // Navigation
DesktopHeader,
MobileHeader,
DesktopFooter,
@@ -46,75 +111,36 @@ import {
} from "@/lib/design-system";
```
-## Component usage guide
-
-| Component | Use Case | Example |
-| --------------- | ------------------- | ---------------------------------------------------- |
-| **Heading** | Page/section titles | `
Title ` |
-| **Text** | Body text, captions | `
Description ` |
-| **Card** | Generic container | `
... ` |
-| **ProductCard** | Product display | `
` |
-| **Input** | Form inputs | `
}/>` |
-
-## Design token reference
-
-Access design tokens directly for custom styling:
-
-```typescript
-import { typography, spacing, colors } from "@/lib/design-system/tokens";
-
-// Typography
-typography.sizes.h1; // Font size for h1
-responsiveTypography.pageTitle; // Responsive title sizing
-
-// Spacing (4px base unit)
-spacing[4]; // 16px
-spacing[8]; // 32px
-
-// Colors (CSS variable references)
-colors.primary;
-colors.muted;
-```
-
-## Further documentation
-
-- **[docs/design-system/](../design-system/)** — Design token documentation
-- **[.planning/codebase/](../../.planning/codebase/)** — Architecture and conventions
+`colors.magPrimary` / `colors.magAccent` / `colors.magBg` / `colors.magText`도 동일 barrel에서 export.
## Component inventory
-Located in `lib/design-system/`:
-
-| Component | Purpose |
-| ------------------------------------ | ---------------------------------------------------- |
-| **tokens.ts** | Design tokens (colors, spacing, typography, shadows) |
-| **Heading, Text** | Typography components with size variants |
-| **Input, SearchInput** | Form inputs with variants |
-| **Card Family** | Base card + Header/Content/Footer + Skeleton |
-| **ProductCard** | Product card with image & description |
-| **GridCard** | Grid layout card variant |
-| **FeedCardBase** | Social feed card variant |
-| **ProfileHeaderCard** | Profile header card |
-| **DesktopHeader** | Desktop navigation header |
-| **MobileHeader** | Mobile navigation with bottom sheet |
-| **DesktopFooter** | Desktop footer with links |
-| **ActionButton** | Interactive action button |
-| **ArtistCard** | Artist/celebrity display card |
-| **Badge** | Badge/tag display |
-| **BottomSheet** | Mobile bottom sheet |
-| **Divider** | Section divider |
-| **GuestButton** | Guest action button |
-| **Hotspot** | Interactive hotspot marker |
-| **LeaderItem** | Leaderboard/ranking item |
-| **LoadingSpinner** | Loading state indicator |
-| **LoginCard** | Login prompt card |
-| **NavBar, NavItem** | Navigation components |
-| **OAuthButton** | OAuth provider button |
-| **RankingItem** | Ranking display item |
-| **SectionHeader** | Section title header |
-| **ShopCarouselCard** | Shop item carousel card |
-| **SpotCard, SpotDetail, SpotMarker** | Spot interaction components |
-| **StatCard** | Statistics display card |
-| **StepIndicator** | Multi-step progress indicator |
-| **Tabs** | Tab navigation |
-| **Tag** | Categorization tag |
+Located in `packages/web/lib/design-system/`:
+
+| Component | Purpose |
+| ---------------------------------------------------------------------- | ---------------------------------------------------- |
+| **tokens.ts** | Design tokens (colors, spacing, typography, shadows) |
+| **Heading, Text** | Typography components with size variants |
+| **Input, SearchInput** | Form inputs with variants |
+| **Card Family** | Base card + Header/Content/Footer + Skeleton |
+| **ProductCard / GridCard** | Product / grid layout cards |
+| **FeedCardBase** | Social feed card |
+| **ProfileHeaderCard** | Profile header card |
+| **DesktopHeader / MobileHeader** | Top navigation (72px / 56px) |
+| **DesktopFooter** | Desktop footer with links |
+| **ActionButton / OAuthButton / GuestButton** | Interactive buttons |
+| **ArtistCard / SpotCard / SpotDetail / SpotMarker** | Domain cards |
+| **Badge / Tag / Divider / Tabs** | Feedback / structure |
+| **BottomSheet / Hotspot / LoadingSpinner / LoginCard / StepIndicator** | Misc UI |
+| **NavBar / NavItem / SectionHeader** | Navigation primitives |
+| **RankingItem / LeaderItem / StatCard / ShopCarouselCard** | Ranking & shop |
+| **DecodedLogo** | WebGL brand mark (`lib/components/DecodedLogo.tsx`) |
+
+## Cross-links
+
+- [docs/design-system/README.md](../design-system/README.md) — v2.2.0 진입점
+- [docs/design-system/content-fundamentals.md](../design-system/content-fundamentals.md) — voice / casing / pricing
+- [docs/design-system/animations.md](../design-system/animations.md) — keyframes 카탈로그
+- [docs/design-system/patterns.md](../design-system/patterns.md) — layout / animation / theme patterns (§5.4 dual palette)
+- [docs/design-system/tokens.md](../design-system/tokens.md) — 통합 토큰 레퍼런스 (Magazine palette 포함)
+- [docs/design-system/decoded.pen](../design-system/decoded.pen) — 시각적 레퍼런스
diff --git a/docs/agent/design-system-summary.md b/docs/agent/design-system-summary.md
index 8aa43499..81728d5c 100644
--- a/docs/agent/design-system-summary.md
+++ b/docs/agent/design-system-summary.md
@@ -2,7 +2,7 @@
title: Design System — Agent Summary
owner: llm
status: draft
-updated: 2026-04-17
+updated: 2026-05-28
tags: [design-system, ui, agent]
related:
- docs/design-system/README.md
@@ -34,4 +34,5 @@ decoded DS v2.0 진입점. 컴포넌트·토큰·사용 규칙을 에이전트
## Recent changes
+- 2026-05-28 v2.2.0: content-fundamentals / animations / dual palette (editorial fixed dark + product `.dark` 토글) / magazine palette tokens (#573) / design-system-llm.md 1-pager SSOT rewrite
- 2026-04-17: 초기 작성 (Phase 1)
diff --git a/docs/database/drift-check.md b/docs/database/drift-check.md
index b6be62a0..7730e48c 100644
--- a/docs/database/drift-check.md
+++ b/docs/database/drift-check.md
@@ -2,19 +2,25 @@
title: DB drift check (nightly)
owner: human
status: approved
-updated: 2026-04-30
+updated: 2026-05-28
tags: [db, ops]
related:
- docs/database/operating-model.md
+ - docs/database/schema-drift-sot.md
- .github/workflows/db-drift-check.yml
+ - .github/workflows/schema-drift.yml
- scripts/check-db-drift.sh
+ - scripts/check-schema-drift.ts
---
-# DB drift check (nightly)
+# DB drift check
PRD ↔ `supabase/migrations` 정합성을 매일 자동 검증한다 (#373).
-## 동작
+본 문서는 **v1 nightly** 채널을 설명한다. PR 단계 TS ↔ DB CHECK constraint
+검증(**v2 PR gate**, 2026-05 landed)은 `docs/database/schema-drift-sot.md` 참조.
+
+## v1 nightly 동작
1. `.github/workflows/db-drift-check.yml` cron — 매일 04:00 KST (UTC 19:00). 수동 실행은 GitHub Actions UI 의 "DB drift check" → Run workflow.
2. `pg_dump --schema-only` PRD → `/tmp/drift-prd.sql`
diff --git a/docs/database/schema-drift-sot.md b/docs/database/schema-drift-sot.md
new file mode 100644
index 00000000..426d5269
--- /dev/null
+++ b/docs/database/schema-drift-sot.md
@@ -0,0 +1,69 @@
+---
+title: Schema drift SOT mapping
+owner: human
+status: approved
+updated: 2026-05-28
+tags: [db, ops, ci]
+related:
+ - .github/workflows/schema-drift.yml
+ - scripts/check-schema-drift.ts
+ - docs/database/drift-check.md
+---
+
+# Schema drift SOT mapping (#373 v2)
+
+본 문서는 **TypeScript 상수 ↔ Postgres CHECK constraint** 매핑을 정의하는
+SSOT(Single Source Of Truth)이다. `.github/workflows/schema-drift.yml` (PR gate)
+가 `scripts/check-schema-drift.ts` 를 실행할 때 본 표의 entry 를 기준으로
+양쪽이 set-equal 한지 검증한다.
+
+## 등록된 SOT
+
+| # | TS 파일 / 상수 | DB 테이블 / 컬럼 / 제약 |
+|---|---|---|
+| 1 | `packages/web/lib/api/admin/magazines.ts` / `MAGAZINE_STATUSES` | `public.post_magazines.status` / `post_magazines_status_check` |
+
+> 매핑 본체는 `scripts/check-schema-drift.ts` 의 `SOT_MAPPINGS` 배열이다.
+> 본 표는 사람이 한눈에 보기 위한 사본 — **두 곳을 함께 갱신**한다.
+
+## 등록 규칙
+
+- TS 상수는 `export const X = [...] as const` 패턴이어야 한다 (script 의 AST
+ 추출은 이 패턴만 지원).
+- DB CHECK constraint 는 `pg_get_constraintdef` 결과가 다음 중 하나여야 한다:
+ - `CHECK ((col)::text = ANY ((ARRAY['a'::text, 'b'::text])::text[]))`
+ - `CHECK (col = ANY (ARRAY['a'::text, ...]))`
+ - `CHECK (col IN ('a','b','c'))`
+- 신규 SOT 추가 시:
+ 1. 본 표에 row 추가
+ 2. `scripts/check-schema-drift.ts` 의 `SOT_MAPPINGS` 에 entry 추가
+ 3. PR 자체 workflow run 으로 자기 자신을 검증
+
+## 알려진 미커버 항목 (v3 후속)
+
+| 항목 | 사유 | 후속 |
+|---|---|---|
+| `public.posts.status` (TS `POST_STATUSES`) | DB 측에 CHECK constraint 가 없음 (`character varying(20)` + default 'active' 만). | 별도 issue — CHECK 추가 마이그레이션 후 본 표 등록 |
+| generated types ↔ DB | `packages/web/lib/api/generated/` 는 OpenAPI 산출물이라 별도 drift 채널 필요. | v3 후속 issue 권장 |
+| `warehouse.*` / `auth.*` schema | v2 는 `public` 만 검증. | 필요 시 별도 mapping 추가 |
+
+## drift 발견 시
+
+PR comment 에 sticky 한 `schema-drift` block 으로 결과가 갱신된다:
+
+- **OK**: drift 없음 — PR check green
+- **DRIFT**: TS / DB set 차이를 표로 표기 — PR check red
+- **PARSE\_FAIL**: constraint 정의를 정규식으로 못 읽음 — raw `pg_get_constraintdef`
+ 출력을 그대로 보여줌 (script bug 가능, 즉시 maintainer 확인)
+
+## 의도적 우회
+
+`drift-bypass` label 을 PR 에 붙이면 step 이 `continue-on-error` 로 전환되어
+job 은 green 이 되지만 drift report 는 그대로 PR comment 에 남는다. branch
+protection rule 은 별도 설정 — 본 workflow 만으로 머지 차단은 보장되지 않는다.
+
+## 관련
+
+- nightly v1: `.github/workflows/db-drift-check.yml` (PRD ↔ supabase/migrations, 유지)
+- #372 trigger 사례: MAGAZINE\_STATUSES vs `post_magazines_status_check` drift
+- 운영 모델: `docs/database/operating-model.md`
diff --git a/docs/design-system/README.md b/docs/design-system/README.md
index 0d19da14..8f01412f 100644
--- a/docs/design-system/README.md
+++ b/docs/design-system/README.md
@@ -1,15 +1,16 @@
# Decoded Design System
-> Version: 2.1.0
-> Last Updated: 2026-02-12
+> Version: 2.2.0
+> Last Updated: 2026-05-28
---
## Overview
-Decoded Design System v2.1은 웹과 모바일 앱 전반에 걸쳐 일관된 사용자 경험을 제공하기 위한 디자인 토큰, 컴포넌트, 패턴을 정의합니다.
+Decoded Design System v2.2는 웹과 모바일 앱 전반에 걸쳐 일관된 사용자 경험을 제공하기 위한 디자인 토큰, 컴포넌트, 패턴, 콘텐츠 규칙을 정의합니다.
**v2.0 주요 변경사항**:
+
- Typography 컴포넌트 (Heading, Text) 추가
- Card 패밀리 확장 (ProductCard, GridCard, FeedCard, ProfileHeaderCard)
- Desktop/Mobile Header 컴포넌트
@@ -17,12 +18,24 @@ Decoded Design System v2.1은 웹과 모바일 앱 전반에 걸쳐 일관된
- 디자인 패턴 가이드 (patterns.md)
**v2.1 추가 변경사항**:
+
- 35개 컴포넌트 라이브러리 완성
- Hotspot/SpotCard/ArtistCard 등 도메인 특화 컴포넌트 추가
- Navigation 컴포넌트 (NavBar, NavItem, SectionHeader)
- Button 컴포넌트 (ActionButton, OAuthButton, GuestButton)
- Feedback 컴포넌트 (Tag, Badge, Divider, Tabs, StepIndicator, LoadingSpinner, LoginCard, BottomSheet)
+**v2.2 변경사항**:
+
+- **Content Fundamentals** 신규 — Voice/Casing/Person/Language/Pricing/Numbers/Emoji 규칙 ([content-fundamentals.md](./content-fundamentals.md))
+- **Animations 카탈로그** 신규 — 15+ CSS keyframes 문서화 ([animations.md](./animations.md))
+- **Dual Palette** 도입 — editorial(magazine palette, fixed dark) ↔ product UI(semantic, next-themes `.dark` 토글) 분리. 자세한 내용 [patterns.md §5.4](./patterns.md)
+- **Magazine palette tokens** — `magPrimary/magAccent/magBg/magText` (#573)
+- **Navigation Facts** — Desktop Header 72px, Mobile Header 56px
+- **Home sequence** — Hero → EditorialMagazine → EditorialCarousel → StyleMoods → EditorPicks → TrendingList → Dome (`app/page.tsx:568`)
+- **IBM Plex Mono** — DecodedLogo / HeroCover inline 사용 (`next/font` 미적용, 점진 마이그레이션 대상)
+- **DecodedLogo WebGL** — three.js + `@chenglou/pretext` shader (`lib/components/DecodedLogo.tsx`)
+
**시각적 참고**: [decoded.pen](./decoded.pen)
---
@@ -31,39 +44,46 @@ Decoded Design System v2.1은 웹과 모바일 앱 전반에 걸쳐 일관된
### Foundation (Tokens)
-| Document | Description | Status |
-|----------|-------------|--------|
-| [tokens.md](./tokens.md) | **통합 토큰 레퍼런스** (Typography, Colors, Spacing, Shadows, Z-Index) | ✅ Complete |
-| [colors.md](./colors.md) | 컬러 시스템 상세 (v1.0 legacy) | Complete |
-| [typography.md](./typography.md) | 타이포그래피 시스템 상세 (v1.0 legacy) | Complete |
-| [spacing.md](./spacing.md) | 간격 및 레이아웃 시스템 상세 (v1.0 legacy) | Complete |
-| [icons.md](./icons.md) | 아이콘 시스템 | Complete |
+| Document | Description | Status |
+| -------------------------------- | ---------------------------------------------------------------------- | ----------- |
+| [tokens.md](./tokens.md) | **통합 토큰 레퍼런스** (Typography, Colors, Spacing, Shadows, Z-Index) | ✅ Complete |
+| [colors.md](./colors.md) | 컬러 시스템 상세 (v1.0 legacy) | Complete |
+| [typography.md](./typography.md) | 타이포그래피 시스템 상세 (v1.0 legacy) | Complete |
+| [spacing.md](./spacing.md) | 간격 및 레이아웃 시스템 상세 (v1.0 legacy) | Complete |
+| [icons.md](./icons.md) | 아이콘 시스템 | Complete |
**💡 Quick Access**: 모든 토큰 값은 [tokens.md](./tokens.md)에서 한 번에 확인할 수 있습니다.
+### Foundation (v2.2 신규)
+
+| Document | Description | Status |
+| ---------------------------------------------------- | -------------------------------------------------------------- | ----------- |
+| [content-fundamentals.md](./content-fundamentals.md) | Voice / Casing / Person / Language / Pricing / Numbers / Emoji | ✅ Complete |
+| [animations.md](./animations.md) | 15+ CSS keyframe 카탈로그 + reduce-motion 정책 | ✅ Complete |
+
### Components (v2.0)
-| Document | Description | Status |
-|----------|-------------|--------|
-| [components/README.md](./components/README.md) | 컴포넌트 인덱스 및 Import 가이드 | ✅ Complete |
-| [components/typography.md](./components/typography.md) | Heading, Text 컴포넌트 | ✅ Complete |
-| [components/inputs.md](./components/inputs.md) | Input, SearchInput 컴포넌트 | ✅ Complete |
-| [components/cards.md](./components/cards.md) | Card 패밀리 (Card, ProductCard, GridCard, FeedCard, ProfileHeaderCard) | ✅ Complete |
-| [components/headers.md](./components/headers.md) | DesktopHeader, MobileHeader, DesktopFooter | ✅ Complete |
+| Document | Description | Status |
+| ------------------------------------------------------ | ---------------------------------------------------------------------- | ----------- |
+| [components/README.md](./components/README.md) | 컴포넌트 인덱스 및 Import 가이드 | ✅ Complete |
+| [components/typography.md](./components/typography.md) | Heading, Text 컴포넌트 | ✅ Complete |
+| [components/inputs.md](./components/inputs.md) | Input, SearchInput 컴포넌트 | ✅ Complete |
+| [components/cards.md](./components/cards.md) | Card 패밀리 (Card, ProductCard, GridCard, FeedCard, ProfileHeaderCard) | ✅ Complete |
+| [components/headers.md](./components/headers.md) | DesktopHeader, MobileHeader, DesktopFooter | ✅ Complete |
### v2.1 Components
-| Component Category | Files | Status |
-|-------------------|-------|--------|
-| **Navigation** | nav-bar.tsx, nav-item.tsx, section-header.tsx | ✅ Complete |
-| **Buttons** | action-button.tsx, oauth-button.tsx, guest-button.tsx | ✅ Complete |
-| **Domain Cards** | artist-card.tsx, spot-card.tsx, spot-detail.tsx, shop-carousel-card.tsx, stat-card.tsx, ranking-item.tsx, leader-item.tsx, skeleton-card.tsx | ✅ Complete |
-| **Feedback** | tag.tsx, badge.tsx, divider.tsx, tabs.tsx, step-indicator.tsx, loading-spinner.tsx, login-card.tsx, bottom-sheet.tsx, hotspot.tsx | ✅ Complete |
+| Component Category | Files | Status |
+| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| **Navigation** | nav-bar.tsx, nav-item.tsx, section-header.tsx | ✅ Complete |
+| **Buttons** | action-button.tsx, oauth-button.tsx, guest-button.tsx | ✅ Complete |
+| **Domain Cards** | artist-card.tsx, spot-card.tsx, spot-detail.tsx, shop-carousel-card.tsx, stat-card.tsx, ranking-item.tsx, leader-item.tsx, skeleton-card.tsx | ✅ Complete |
+| **Feedback** | tag.tsx, badge.tsx, divider.tsx, tabs.tsx, step-indicator.tsx, loading-spinner.tsx, login-card.tsx, bottom-sheet.tsx, hotspot.tsx | ✅ Complete |
### Patterns
-| Document | Description | Status |
-|----------|-------------|--------|
+| Document | Description | Status |
+| ---------------------------- | -------------------------------------------------------------------- | ----------- |
| [patterns.md](./patterns.md) | 디자인 패턴 가이드 (레이아웃, 애니메이션, 상태 관리, 컴포지션, 테마) | ✅ Complete |
---
@@ -86,7 +106,7 @@ import { Heading, Text, Card, ProductCard, Input } from "@/lib/design-system";
// Tailwind CSS 클래스 사용 (권장)
Card content
-
+
;
// 또는 토큰 직접 사용
import { colors, spacing } from "@/lib/design-system";
@@ -99,14 +119,22 @@ const styles = {
### 3. Use Components
```tsx
-import { Heading, Text, Card, CardHeader, CardContent } from "@/lib/design-system";
+import {
+ Heading,
+ Text,
+ Card,
+ CardHeader,
+ CardContent,
+} from "@/lib/design-system";
function MyComponent() {
return (
Card Title
- Subtitle
+
+ Subtitle
+
Card content goes here
@@ -168,57 +196,81 @@ Card 컴포넌트는 slot composition 패턴을 사용합니다:
### Typography
-| Variant | Size (Desktop) | Usage |
-|---------|---------------|-------|
-| hero | 64px+ | Hero 섹션 대제목 |
-| h1 | 48px+ | 페이지 제목 |
-| h2 | 36px+ | 섹션 제목 |
-| h3 | 28px+ | 서브섹션 제목 |
-| h4 | 24px+ | 카드 제목 |
-| body | 16px | 본문 텍스트 |
-| small | 14px | 보조 텍스트 |
-| caption | 12px | 캡션, 메타 |
+| Variant | Size (Desktop) | Usage |
+| ------- | -------------- | ---------------- |
+| hero | 64px+ | Hero 섹션 대제목 |
+| h1 | 48px+ | 페이지 제목 |
+| h2 | 36px+ | 섹션 제목 |
+| h3 | 28px+ | 서브섹션 제목 |
+| h4 | 24px+ | 카드 제목 |
+| body | 16px | 본문 텍스트 |
+| small | 14px | 보조 텍스트 |
+| caption | 12px | 캡션, 메타 |
### Colors (Semantic)
-| Token | Usage |
-|-------|-------|
-| primary | 주요 액션 (CTA 버튼, 링크) |
-| secondary | 보조 액션, 비활성 상태 |
-| destructive | 삭제, 위험 액션 |
-| muted | 비활성 배경, 보조 텍스트 |
-| accent | 강조 요소 (hover, focus) |
+| Token | Usage |
+| ----------- | -------------------------- |
+| primary | 주요 액션 (CTA 버튼, 링크) |
+| secondary | 보조 액션, 비활성 상태 |
+| destructive | 삭제, 위험 액션 |
+| muted | 비활성 배경, 보조 텍스트 |
+| accent | 강조 요소 (hover, focus) |
### Spacing Scale
-| Token | Value | Usage |
-|-------|-------|-------|
-| 2 | 8px | 컴포넌트 내부 최소 |
-| 4 | 16px | 컴포넌트 내부 기본 |
-| 6 | 24px | 컴포넌트 간 간격 |
-| 8 | 32px | 섹션 간 간격 |
+| Token | Value | Usage |
+| ----- | ------- | -------------------- |
+| 2 | 8px | 컴포넌트 내부 최소 |
+| 4 | 16px | 컴포넌트 내부 기본 |
+| 6 | 24px | 컴포넌트 간 간격 |
+| 8 | 32px | 섹션 간 간격 |
| 10-16 | 40-64px | 페이지 여백 (반응형) |
### Breakpoints
-| Name | Width | Usage |
-|------|-------|-------|
-| sm | 640px | 작은 태블릿 이상 |
-| md | 768px | 태블릿 이상 (Desktop Header 표시) |
-| lg | 1024px | 데스크탑 이상 |
-| xl | 1280px+ | 큰 데스크탑 |
+| Name | Width | Usage |
+| ---- | ------- | --------------------------------- |
+| sm | 640px | 작은 태블릿 이상 |
+| md | 768px | 태블릿 이상 (Desktop Header 표시) |
+| lg | 1024px | 데스크탑 이상 |
+| xl | 1280px+ | 큰 데스크탑 |
---
## Tech Stack
-| Layer | Technology |
-|-------|------------|
-| CSS | Tailwind CSS 3.4 |
-| Color System | OKLCH |
-| Theme | next-themes (Light/Dark) |
-| Animations | GSAP, Motion |
-| Icons | Lucide Icons |
+| Layer | Technology |
+| ------------ | ------------------------------------------------------------------------------------------------------------- |
+| CSS | Tailwind CSS 3.4 |
+| Color System | OKLCH (semantic) + Hex (magazine palette `--mag-*`) |
+| Theme | next-themes (`.dark` flip on product UI only — editorial 표면은 magazine palette 고정) |
+| Typography | Playfair Display (serif), Inter (sans), JetBrains Mono (code), IBM Plex Mono (DecodedLogo / HeroCover inline) |
+| Animations | CSS keyframes ([animations.md](./animations.md)), GSAP Flip, Motion, Lenis |
+| 3D / Shader | three.js + `@chenglou/pretext` (DecodedLogo WebGL) |
+| Icons | Lucide Icons |
+
+## Navigation Facts
+
+| Surface | Height | Source |
+| ---------------- | ------------------------ | -------------------------------------- |
+| Desktop Header | 72px | `lib/design-system/desktop-header.tsx` |
+| Mobile Header | 56px | `lib/design-system/mobile-header.tsx` |
+| 본문 top padding | `pt-[56px] md:pt-[72px]` | [patterns.md §1.4](./patterns.md) |
+
+## Home Page Sequence
+
+`packages/web/app/page.tsx:568` 기준 메인 페이지 섹션 순서:
+
+1. `HeroItemSync` — `md` 이상에서만 노출
+2. `EditorialMagazine`
+3. `EditorialCarousel`
+4. `StyleMoods`
+5. `EditorPicks` — data 존재 시
+6. `TrendingListSection`
+7. `DomeGallerySection` — data 존재 시
+
+루트 컨테이너는 magazine palette 고정 dark: ``.
---
diff --git a/docs/design-system/animations.md b/docs/design-system/animations.md
new file mode 100644
index 00000000..284b3e8c
--- /dev/null
+++ b/docs/design-system/animations.md
@@ -0,0 +1,134 @@
+# Animations
+
+> Version: 2.2.0
+> Last Updated: 2026-05-28
+
+---
+
+## Overview
+
+Decoded의 모든 CSS keyframe 애니메이션 카탈로그입니다. JavaScript 기반(GSAP/Motion) 애니메이션은 [patterns.md](./patterns.md) §2를 참조하세요.
+
+**관련 문서**:
+
+- [patterns.md](./patterns.md) — 애니메이션 패턴 (Motion, GSAP, Lenis)
+- [tokens.md](./tokens.md) — 시각 토큰
+- 코드 앵커: `packages/web/app/globals.css`
+
+---
+
+## Easing 함수
+
+| Name | 값 | 사용처 |
+| -------------------------------- | --------------- | --------------------------------------------------------------------------- |
+| `ease-out` | 표준 | 단방향 reveal (`spot-reveal`, `slide-up`, `reveal-scan`) |
+| `ease-in-out` | 표준 | 반복 펄스 (`hologram-*`, `spot-glow`, `spot-float`, `card-glow`, `shimmer`) |
+| `linear` | 표준 | 무한 스크롤·플로우 (`dash-flow`, `marquee-c`) |
+| `cubic-bezier(0.22, 1, 0.36, 1)` | observe pattern | `.js-observe` 진입/이탈 (opacity/transform 0.34–0.36s) |
+| `cubic-bezier(0.16, 1, 0.3, 1)` | smooth reveal | `ai-summary-reveal` |
+
+기본 transition 길이는 **300–400ms** (observe 패턴 기준). 반복 펄스는 1.5–2.5s.
+
+---
+
+## Keyframe 카탈로그
+
+| Keyframe | 위치 (globals.css) | Duration / Easing | 용도 | reduce-motion |
+| ------------------- | ------------------ | ------------------------------------------- | --------------------------------------------------- | ----------------------------------- |
+| `dash-flow` | L299 | 3s linear infinite | Fashion Scan SVG connector (stroke-dashoffset 흐름) | `.fs-connector { animation: none }` |
+| `hologram-scan` | L318 | 2.5s ease-in-out infinite | Request flow 홀로그램 수직 스캔 | none |
+| `hologram-pulse` | L329 | 2s ease-in-out infinite | 홀로그램 opacity 펄스 | none |
+| `hologram-glow` | L339 | 1.5s ease-in-out infinite | 홀로그램 drop-shadow 강도 펄스 | none |
+| `corner-pulse` | L351 | 1s ease-in-out infinite | 홀로그램 코너 마커 | none |
+| `spot-reveal` | L410 | 0.4s ease-out forwards | Spot 마커 등장 (scale 0→1.3→1) | none |
+| `reveal-scan` | L425 | 1.2s ease-out forwards | Spot reveal 수직 스캔 라인 | none |
+| `spot-glow` | L444 | 1.5s ease-in-out infinite | Spot 마커 ring + glow 펄스 | none |
+| `spot-float` | L458 | 2.5s ease-in-out infinite | Spot 마커 부드러운 호흡 (scale 1→1.08) | none |
+| `slide-up` | L487 | 0.3s ease-out forwards | BottomSheet 등장 | none |
+| `shimmer` | L503 | 1.5s ease-in-out infinite | Skeleton 로딩 shimmer | (skeleton 자체는 유지) |
+| `ai-summary-reveal` | L522 | 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards | AI 요약 카드 blur+slide 진입 | none |
+| `ai-summary-scan` | L535 | (자유) | AI 요약 진입 스캔 라인 | none |
+| `card-glow` | L551 | 2s ease-in-out infinite | 카드 선택 강조 glow | none |
+| `marquee-c` | L585 | 30s linear infinite | 푸터 'C' 마퀴 횡스크롤 | none |
+
+**옵저버 패턴** (.js-observe): keyframe 없이 transition 기반.
+
+- 위치: `globals.css:242`
+- transition: `opacity 0.34s, transform 0.36s` (`cubic-bezier(0.22, 1, 0.36, 1)`)
+- 트리거: IntersectionObserver → `.is-visible` / `.is-hidden` 클래스 토글
+
+---
+
+## prefers-reduced-motion 정책
+
+`globals.css`에 4개의 `@media (prefers-reduced-motion: reduce)` 블록이 있습니다:
+
+| 위치 | 비활성화 대상 |
+| ---- | ------------------------------------------------------------------------------------------------------------------------- |
+| L311 | `.fs-connector` |
+| L400 | `.animate-hologram-scan`, `.animate-hologram-pulse`, `.animate-hologram-glow`, `.animate-corner-pulse` |
+| L476 | `.animate-spot-reveal`, `.animate-reveal-scan`, `.animate-spot-glow`, `.animate-spot-float`, `.animate-ai-summary-reveal` |
+| L598 | `.animate-slide-up`, `.animate-card-glow`, `.animate-marquee-c` |
+
+`.js-observe`는 L278에서 `transition: none`으로 전환되고, `.is-visible/.is-hidden`은 L284에서 `opacity: 1; transform: none`으로 즉시 표시됩니다.
+
+**가이드**: 새 keyframe 추가 시 동일 파일의 `@media (prefers-reduced-motion: reduce)` 블록에 utility 클래스를 추가하세요.
+
+---
+
+## 사용 예시
+
+### 1. Spot 마커
+
+```tsx
+
+
+ {/* Spot marker */}
+
+
+```
+
+### 2. Skeleton 로딩
+
+```tsx
+
+```
+
+### 3. AI 요약 진입
+
+```tsx
+
+
+
+```
+
+### 4. 스크롤 등장 (observe pattern)
+
+```tsx
+// IntersectionObserver 훅과 함께 사용
+
+```
+
+`useIntersectionObserver` 훅이 `.is-visible` 클래스를 토글합니다. 코드 앵커: `packages/web/lib/hooks/`.
+
+---
+
+## Motion / GSAP
+
+CSS keyframe으로 표현하기 어려운 상호작용은 JS 라이브러리를 사용합니다. 자세한 패턴은 [patterns.md §2](./patterns.md) 참조.
+
+- **Motion (framer-motion)**: AnimatePresence, layoutId, variants
+- **GSAP Flip**: 그리드 레이아웃 전환
+- **Lenis**: smooth scroll inertia
+
+---
+
+## Related
+
+- [patterns.md](./patterns.md) — JS 애니메이션 패턴
+- [tokens.md](./tokens.md) — Z-index, shadows
+- `packages/web/app/globals.css` — keyframe 정의
diff --git a/docs/design-system/content-fundamentals.md b/docs/design-system/content-fundamentals.md
new file mode 100644
index 00000000..c062a317
--- /dev/null
+++ b/docs/design-system/content-fundamentals.md
@@ -0,0 +1,155 @@
+# Content Fundamentals
+
+> Version: 2.2.0
+> Last Updated: 2026-05-28
+
+---
+
+## Overview
+
+Decoded의 표면(home, magazine, product UI)이 일관된 톤·표기·언어 규칙을 따르도록 하는 콘텐츠 가이드입니다. 시각 토큰이 색·간격·타이포를 규정하듯, 본 문서는 **단어 선택과 표기**의 SSOT입니다.
+
+**관련 문서**:
+
+- [README](./README.md) — 디자인 시스템 진입점
+- [tokens.md](./tokens.md) — 시각 토큰
+- [patterns.md](./patterns.md) — 디자인 패턴 (Dual Palette 포함)
+
+---
+
+## 1. Voice (어조)
+
+editorial(home/magazine/spot detail) 표면과 product UI(explore/profile/rankings/admin/auth/upload/settings) 표면은 톤이 다릅니다.
+
+| 표면 | 어조 | 예시 |
+| ---------------------------------- | ------------------------------ | -------------------------- |
+| Editorial (home/magazine) | 큐레이션, 단정적, 시적 | "이번 호 — 봄의 결" |
+| Product UI (explore/profile/admin) | 명료, 기능 중심, 군더더기 없음 | "결과 24개", "프로필 편집" |
+
+**가이드**:
+
+- Editorial: 호명·제호·연도 사용 가능 ("Vol. 12", "2026 Spring Issue").
+- Product UI: 사용자 행동을 명사화하지 않음. "저장하기" (O), "저장의 행위" (X).
+
+---
+
+## 2. Casing (대소문자/표기)
+
+- **영문 제목**: 고유명사 외에는 sentence case 우선. 단, editorial 헤드라인은 ALL CAPS 또는 small caps 허용 (Playfair Display 기준).
+- **버튼 라벨**: 한국어 위주. 영문이 필요한 경우 sentence case ("Sign up"), title case 금지.
+- **태그/카테고리**: 소문자 단일 단어 권장 ("denim", "vintage"). 다단어는 케밥 케이스 ("street-style").
+
+**코드 앵커**: `packages/web/lib/design-system/tag.tsx`, `packages/web/lib/components/main-renewal/HeroCover.tsx`
+
+---
+
+## 3. Person (인칭)
+
+- **Editorial 보이스**: 3인칭 또는 무인칭. "에디터의 선택", "이번 주의 무드".
+- **Product UI**: 2인칭 "당신" 지양. 시스템 음성("프로필이 저장되었습니다") 또는 1인칭 사용자 음성("내 컬렉션") 사용.
+- **에러/안내**: 비난조 금지. "잘못된 입력입니다" (X) → "다시 확인해주세요" (O).
+
+---
+
+## 4. Language (언어)
+
+- **기본 언어**: 한국어 (ko-KR).
+- **혼용 정책**: 고유명사·브랜드·기술 용어는 원문 유지 ("Levi's", "OAuth", "Next.js"). 일반 명사는 한국어 우선.
+- **번역 일관성**:
+ - "Save" → "저장" (X "세이브")
+ - "Settings" → "설정" (X "환경설정", "프리퍼런스")
+ - "Profile" → "프로필" (X "개인정보")
+
+---
+
+## 5. Pricing (가격 표시)
+
+**규칙**: 가격은 `ko-KR` 로케일 + `₩` 통화 기호.
+
+```tsx
+// ✅ 권장 — toLocaleString + 통화 기호 prepend
+const price = 89000;
+const label = `₩${price.toLocaleString("ko-KR")}`; // "₩89,000"
+
+// ✅ Intl.NumberFormat 사용도 가능
+const formatter = new Intl.NumberFormat("ko-KR", {
+ style: "currency",
+ currency: "KRW",
+});
+formatter.format(89000); // "₩89,000"
+
+// ❌ 금지
+const bad = `$${price}`; // 통화 단위 불일치
+const bad2 = `KRW ${price}`; // 코드 노출 (사용자 표면)
+```
+
+**코드 앵커**: `packages/web/lib/components/main/DecodedSolutionsSection.tsx:40` — 현재 일부 표면에서 `KRW ${price.toLocaleString()}` 사용 중 (점진적 마이그레이션 대상).
+
+**예외**: 관리자(seed-ops) 페이지는 ISO 통화 코드 노출 허용 ("KRW 89,000") — 운영 메타 데이터이기 때문.
+
+---
+
+## 6. Numbers (숫자 포맷)
+
+**규칙**: 사용자 표면의 모든 숫자는 `toLocaleString()` 통과.
+
+| 컨텍스트 | 포맷 | 예시 |
+| --------------------- | --------------------------- | ------------------------- |
+| 카운트 (좋아요, 댓글) | `n.toLocaleString("ko-KR")` | "1,234" |
+| 큰 단위 축약 | k/M 접미 (선택) | "12.3k" (10,000 이상) |
+| 비율/퍼센트 | 정수 % | "87%" |
+| 날짜 | ISO 또는 한국어 | "2026-05-28" / "5월 28일" |
+
+```tsx
+// ✅ 권장
+const likes = 12345;
+
{likes.toLocaleString()} // "12,345"
+
+// ❌ 금지 — raw number
+
{likes} // "12345"
+```
+
+**코드 앵커**: `packages/web/lib/utils/format.ts:36`
+
+---
+
+## 7. Emoji Policy
+
+- **Editorial 표면**: 이모지 금지. 톤이 깨집니다.
+- **Product UI**: 시스템 알림·토스트에서 절제 사용 허용 (성공/오류 1개). 본문 텍스트에서는 지양.
+- **관리자/seed-ops**: 운영 컨텍스트에서 필요 시 허용 (예: 상태 마커).
+- **이모지 대신 아이콘**: 가능한 한 Lucide 아이콘 사용 ([icons.md](./icons.md) 참조).
+
+```tsx
+// ✅ Editorial — 아이콘
+import { Sparkles } from "lucide-react";
+
이번 호의 픽
+
+// ❌ Editorial — 이모지
+
✨ 이번 호의 픽
+
+// ✅ Toast — 절제된 사용 OK
+toast.success("저장되었습니다");
+```
+
+---
+
+## Do / Don't 요약
+
+| Do | Don't |
+| ------------------------------------------ | ------------------------------------- |
+| 가격은 `₩` + `toLocaleString("ko-KR")` | `$`, `KRW` 코드, raw 숫자 |
+| Editorial = 큐레이션 톤, Product UI = 명료 | 두 표면 톤 혼용 |
+| 영문은 sentence case 기본 | Title Case Every Word |
+| 한국어 우선, 고유명사 원문 | "환경설정", "프리퍼런스" 등 자체 번역 |
+| 시스템 음성 또는 1인칭 ("내 컬렉션") | 2인칭 "당신" 남용 |
+| Lucide 아이콘 | Editorial 표면에 이모지 |
+
+---
+
+## Related
+
+- [README](./README.md)
+- [patterns.md](./patterns.md) — Section 5.4 Dual Palette Usage
+- [icons.md](./icons.md)
+- [docs/agent/design-system-llm.md](../agent/design-system-llm.md) — LLM SSOT
diff --git a/docs/design-system/patterns.md b/docs/design-system/patterns.md
index 534c2e96..845d67dc 100644
--- a/docs/design-system/patterns.md
+++ b/docs/design-system/patterns.md
@@ -1,7 +1,7 @@
# Design Patterns
-> Version: 2.0.0
-> Last Updated: 2026-02-05
+> Version: 2.2.0
+> Last Updated: 2026-05-28
---
@@ -11,6 +11,7 @@
레이아웃, 애니메이션, 상태 관리, 컴포지션, 테마 패턴을 다룹니다.
**관련 문서**:
+
- [Design Tokens](./tokens.md)
- [Components Index](./components/README.md)
- [decoded.pen](./decoded.pen)
@@ -26,7 +27,7 @@
```tsx
// 모바일: 2열, 태블릿: 3열, 데스크탑: 4열
- {items.map(item => (
+ {items.map((item) => (
))}
@@ -37,7 +38,7 @@
```tsx
// 모바일: 1열, 태블릿: 2열, 데스크탑: 3열
- {posts.map(post => (
+ {posts.map((post) => (
))}
@@ -48,7 +49,7 @@
```tsx
// CSS Grid masonry (실험적)
- {images.map(image => (
+ {images.map((image) => (
- Section Title
+
+ Section Title
+
{/* Content */}
```
**패턴 설명**:
+
- `py-10 md:py-16`: 섹션 상하 패딩 (모바일 40px, 데스크탑 64px)
- `max-w-7xl mx-auto`: 최대 너비 제한 + 중앙 정렬
- `px-4 md:px-6 lg:px-8`: 좌우 패딩 (반응형)
@@ -169,6 +173,7 @@ function TabPanel({ activeTab, children }) {
```
**패턴 설명**:
+
- `mode="wait"`: 이전 요소가 exit 완료 후 새 요소 enter
- `key={activeTab}`: 탭 변경 시 애니메이션 트리거
- Fade + Slide 조합 (opacity + y)
@@ -181,7 +186,7 @@ import { motion } from "motion/react";
function Tabs({ tabs, activeTab, onChange }) {
return (
- {tabs.map(tab => (
+ {tabs.map((tab) => (
onChange(tab.id)}
@@ -204,6 +209,7 @@ function Tabs({ tabs, activeTab, onChange }) {
```
**패턴 설명**:
+
- `layoutId="activeTab"`: 동일한 layoutId를 가진 요소 간 자동 애니메이션
- Spring 애니메이션 (자연스러운 움직임)
@@ -231,8 +237,11 @@ function GridLayout({ layout }) {
}, [layout]);
return (
-
- {items.map(item => (
+
+ {items.map((item) => (
@@ -243,6 +252,7 @@ function GridLayout({ layout }) {
```
**패턴 설명**:
+
- GSAP Flip: 레이아웃 변경 시 부드러운 위치/크기 전환
- `absolute: true`: 절대 위치 기반 애니메이션
@@ -277,6 +287,7 @@ export function SmoothScrollProvider({ children }) {
```
**패턴 설명**:
+
- Lenis: 부드러운 스크롤 효과 (inertia scrolling)
- Root layout에서 한 번만 초기화
@@ -289,7 +300,7 @@ import { AnimatePresence, motion } from "motion/react";
function RequestFlow({ step, direction }) {
const variants = {
enter: (direction) => ({
- x: direction === 'forward' ? '100%' : '-100%',
+ x: direction === "forward" ? "100%" : "-100%",
opacity: 0,
}),
center: {
@@ -297,7 +308,7 @@ function RequestFlow({ step, direction }) {
opacity: 1,
},
exit: (direction) => ({
- x: direction === 'forward' ? '-100%' : '100%',
+ x: direction === "forward" ? "-100%" : "100%",
opacity: 0,
}),
};
@@ -321,6 +332,7 @@ function RequestFlow({ step, direction }) {
```
**패턴 설명**:
+
- 방향에 따라 다른 enter/exit 애니메이션
- `custom` prop으로 direction 전달
@@ -361,8 +373,9 @@ function ProductGrid({ products, isLoading }) {
{isLoading
? [...Array(8)].map((_, i) =>
)
- : products.map(product =>
)
- }
+ : products.map((product) => (
+
+ ))}
);
}
@@ -400,7 +413,9 @@ function ErrorState({ title, message, action }) {
- {title}
+
+ {title}
+
{message}
{action && (
@@ -426,8 +441,12 @@ function EmptyState({ icon: Icon = Package, title, message, action }) {
- {title}
- {message}
+
+ {title}
+
+
+ {message}
+
{action && (
@@ -465,7 +484,14 @@ import { CardSkeleton } from "@/lib/design-system";
### 4.1 Card Slot Composition
```tsx
-import { Card, CardHeader, CardContent, CardFooter, Heading, Text } from "@/lib/design-system";
+import {
+ Card,
+ CardHeader,
+ CardContent,
+ CardFooter,
+ Heading,
+ Text,
+} from "@/lib/design-system";
function ArticleCard({ article }) {
return (
@@ -491,6 +517,7 @@ function ArticleCard({ article }) {
```
**패턴 설명**:
+
- 각 슬롯은 독립적으로 사용 가능 (optional)
- CardHeader, CardContent, CardFooter가 자동으로 spacing 적용
@@ -505,10 +532,11 @@ import { Slot } from "@radix-ui/react-slot";
Clickable card as Link
-
+;
```
**패턴 설명**:
+
- `asChild`: 자식 요소에 props 병합 (Radix UI 패턴)
- Card 스타일 유지하면서 Link로 렌더링
@@ -524,7 +552,7 @@ import Link from "next/link";
Card content
-
+;
```
#### onClick Handler
@@ -536,6 +564,7 @@ import Link from "next/link";
```
**패턴 설명**:
+
- `interactive` prop: `cursor-pointer` + `hover:shadow-lg` 자동 적용
- Link 또는 onClick 중 하나만 사용
@@ -557,6 +586,7 @@ return content;
```
**패턴 설명**:
+
- link prop 있으면 Link로 래핑
- onClick prop 있으면 div로 래핑
- 둘 다 없으면 bare Card 반환
@@ -570,12 +600,12 @@ return content;
```css
/* globals.css */
:root {
- --primary: oklch(0.21 0.006 285.75); /* Light mode */
+ --primary: oklch(0.21 0.006 285.75); /* Light mode */
--primary-foreground: oklch(0.98 0 0);
}
.dark {
- --primary: oklch(0.98 0 0); /* Dark mode */
+ --primary: oklch(0.98 0 0); /* Dark mode */
--primary-foreground: oklch(0.21 0.006 285.75);
}
```
@@ -586,13 +616,11 @@ return content;
// Tailwind 클래스 사용 (권장)
Primary color (자동 테마 전환)
-
+ ;
// 토큰 사용
import { colors } from "@/lib/design-system";
-
- Primary color
-
+
Primary color
;
```
### 5.2 next-themes ThemeProvider
@@ -629,7 +657,11 @@ function ThemeToggle() {
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-md hover:bg-accent"
>
- {theme === "dark" ?
:
}
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
);
}
@@ -637,13 +669,13 @@ function ThemeToggle() {
### 5.3 색상 사용 가이드
-| 색상 | 용도 | 예시 |
-|------|------|------|
-| `primary` | 주요 액션 (CTA 버튼, 링크) | "Buy Now", "Sign Up" 버튼 |
-| `secondary` | 보조 액션, 비활성 상태 | "Cancel", "Skip" 버튼 |
-| `destructive` | 삭제, 위험한 액션 | "Delete", "Remove" 버튼 |
-| `muted` | 보조 텍스트, 비활성 상태 | 캡션, timestamp |
-| `accent` | 강조 요소 (hover, focus) | 버튼 hover 배경 |
+| 색상 | 용도 | 예시 |
+| ------------- | -------------------------- | ------------------------- |
+| `primary` | 주요 액션 (CTA 버튼, 링크) | "Buy Now", "Sign Up" 버튼 |
+| `secondary` | 보조 액션, 비활성 상태 | "Cancel", "Skip" 버튼 |
+| `destructive` | 삭제, 위험한 액션 | "Delete", "Remove" 버튼 |
+| `muted` | 보조 텍스트, 비활성 상태 | 캡션, timestamp |
+| `accent` | 강조 요소 (hover, focus) | 버튼 hover 배경 |
**올바른 사용**:
@@ -658,6 +690,50 @@ function ThemeToggle() {
Muted text // 보조 텍스트는 muted 사용
```
+### 5.4 Dual Palette Usage
+
+decoded v2.2부터 **두 개의 독립된 팔레트**를 surface별로 분리해 사용한다. 둘은 절대 섞지 않는다.
+
+#### Editorial surfaces — Magazine palette (fixed dark)
+
+- 대상: `/` (home), `/magazine/*`, spot detail 등 에디토리얼 표면
+- 토큰: `magPrimary`, `magAccent`, `magBg`, `magText` (`--mag-*` CSS vars, hex 값 고정)
+- 테마 전환 없음 — magazine palette는 항상 dark, light variant 미존재
+- `next-themes`의 `.dark` 스코프를 적용하지 않음 (테마 토글 무관)
+- ThemeToggle 컴포넌트는 editorial surfaces에서 **숨김**
+
+```tsx
+// app/page.tsx — home (editorial)
+
+
+
+ {/* magazine palette 고정, ThemeToggle 미노출 */}
+
+```
+
+#### Product UI — Semantic OKLCH (`.dark` 토글)
+
+- 대상: `/explore`, `/profile`, `/rankings`, `/admin`, `/auth`, `/upload`, `/settings`
+- 토큰: `primary`, `secondary`, `muted`, `accent`, `destructive` 등 semantic OKLCH (`--*` CSS vars)
+- `next-themes` `attribute="class"` 기반 `.dark` 스코프 활성 — light/dark 자동 전환
+- ThemeToggle은 product UI에서만 노출
+
+```tsx
+// app/explore/page.tsx — product UI
+
+ {/* semantic tokens, next-themes로 light/dark 자동 전환 */}
+
+
+```
+
+#### No-mix rule
+
+- editorial 표면에서 `bg-primary`, `text-foreground` 등 semantic 토큰 사용 금지
+- product UI에서 `bg-[#050505]`, `text-[var(--mag-text)]` 등 magazine palette 직접 참조 금지
+- 두 팔레트가 한 페이지에서 공존해야 한다면 그 페이지는 surface 경계가 잘못 그어진 것 — 라우트를 분할
+
+상세 토큰 매핑은 [tokens.md](./tokens.md) Magazine Palette 섹션 참조.
+
---
## 6. 코드 예시 (종합)
@@ -701,10 +777,9 @@ function ProductListPage() {
{isLoading
? [...Array(12)].map((_, i) =>
)
- : products.map(product => (
+ : products.map((product) => (
- ))
- }
+ ))}
@@ -724,14 +799,14 @@ import { AnimatePresence, motion } from "motion/react";
import { FeedCardBase, Heading } from "@/lib/design-system";
function FeedPage() {
- const [activeTab, setActiveTab] = useState('trending');
+ const [activeTab, setActiveTab] = useState("trending");
const { data: posts } = usePosts(activeTab);
return (
{/* Tabs */}
- {['trending', 'following', 'recent'].map(tab => (
+ {["trending", "following", "recent"].map((tab) => (
setActiveTab(tab)}
@@ -758,7 +833,7 @@ function FeedPage() {
exit={{ opacity: 0, y: -10 }}
>
- {posts.map(post => (
+ {posts.map((post) => (
))}
diff --git a/docs/design-system/tokens.md b/docs/design-system/tokens.md
index b13bc34c..57dc6a34 100644
--- a/docs/design-system/tokens.md
+++ b/docs/design-system/tokens.md
@@ -1,7 +1,7 @@
# Design Tokens
-> Version: 2.0.0
-> Last Updated: 2026-02-05
+> Version: 2.2.0
+> Last Updated: 2026-05-28
---
@@ -18,38 +18,38 @@ Decoded Design System의 토큰은 디자인 일관성을 위한 단일 진실
### Font Families
-| 토큰 | 폰트 | 용도 | Fallback |
-|------|------|------|----------|
-| `serif` | Playfair Display | 제목, 브랜드, Hero 텍스트 | Georgia, serif |
-| `sans` | Inter | 본문, UI, 버튼 | system-ui, sans-serif |
-| `mono` | JetBrains Mono | 코드, 기술 텍스트 | Consolas, monospace |
+| 토큰 | 폰트 | 용도 | Fallback |
+| ------- | ---------------- | ------------------------- | --------------------- |
+| `serif` | Playfair Display | 제목, 브랜드, Hero 텍스트 | Georgia, serif |
+| `sans` | Inter | 본문, UI, 버튼 | system-ui, sans-serif |
+| `mono` | JetBrains Mono | 코드, 기술 텍스트 | Consolas, monospace |
### Font Sizes
모든 사이즈는 `typography.sizes` 객체에 정의되어 있습니다:
-| 토큰 | fontSize | lineHeight | fontWeight | letterSpacing | 용도 |
-|------|----------|------------|------------|---------------|------|
-| `hero` | 64px | 1.1 | 700 | -0.025em | Hero 섹션 대제목 |
-| `h1` | 48px | 1.15 | 600 | -0.025em | 페이지 제목 |
-| `h2` | 36px | 1.2 | 600 | -0.02em | 섹션 제목 |
-| `h3` | 28px | 1.25 | 600 | -0.02em | 서브섹션 제목 |
-| `h4` | 24px | 1.3 | 600 | -0.02em | 카드 제목 |
-| `body` | 16px | 1.5 | 400 | 0 | 본문 텍스트 (기본) |
-| `small` | 14px | 1.5 | 400 | 0 | 보조 텍스트 |
-| `caption` | 12px | 1.4 | 400 | 0 | 캡션, 메타 정보 |
+| 토큰 | fontSize | lineHeight | fontWeight | letterSpacing | 용도 |
+| --------- | -------- | ---------- | ---------- | ------------- | ------------------ |
+| `hero` | 64px | 1.1 | 700 | -0.025em | Hero 섹션 대제목 |
+| `h1` | 48px | 1.15 | 600 | -0.025em | 페이지 제목 |
+| `h2` | 36px | 1.2 | 600 | -0.02em | 섹션 제목 |
+| `h3` | 28px | 1.25 | 600 | -0.02em | 서브섹션 제목 |
+| `h4` | 24px | 1.3 | 600 | -0.02em | 카드 제목 |
+| `body` | 16px | 1.5 | 400 | 0 | 본문 텍스트 (기본) |
+| `small` | 14px | 1.5 | 400 | 0 | 보조 텍스트 |
+| `caption` | 12px | 1.4 | 400 | 0 | 캡션, 메타 정보 |
### Responsive Typography
반응형 타이포그래피 스케일(`responsiveTypography` 객체):
-| 요소 | Mobile | Tablet | Desktop |
-|------|--------|--------|---------|
-| `pageTitle` | text-2xl (24px) | text-3xl (30px) | text-4xl (36px) |
-| `sectionTitle` | text-xl (20px) | text-2xl (24px) | text-2xl (24px) |
-| `cardTitle` | text-base (16px) | text-lg (18px) | text-lg (18px) |
-| `body` | text-sm (14px) | text-base (16px) | text-base (16px) |
-| `caption` | text-xs (12px) | text-xs (12px) | text-sm (14px) |
+| 요소 | Mobile | Tablet | Desktop |
+| -------------- | ---------------- | ---------------- | ---------------- |
+| `pageTitle` | text-2xl (24px) | text-3xl (30px) | text-4xl (36px) |
+| `sectionTitle` | text-xl (20px) | text-2xl (24px) | text-2xl (24px) |
+| `cardTitle` | text-base (16px) | text-lg (18px) | text-lg (18px) |
+| `body` | text-sm (14px) | text-base (16px) | text-base (16px) |
+| `caption` | text-xs (12px) | text-xs (12px) | text-sm (14px) |
**사용 예시**:
@@ -76,71 +76,84 @@ import { responsiveTypography } from "@/lib/design-system";
#### Base Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
+| 토큰 | CSS Variable | 용도 |
+| ------------ | -------------- | ---------------- |
| `background` | `--background` | 페이지 기본 배경 |
| `foreground` | `--foreground` | 기본 텍스트 색상 |
#### Component Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
-| `card` | `--card` | 카드 배경 |
-| `cardForeground` | `--card-foreground` | 카드 텍스트 |
-| `popover` | `--popover` | 팝오버 배경 |
+| 토큰 | CSS Variable | 용도 |
+| ------------------- | ---------------------- | ------------- |
+| `card` | `--card` | 카드 배경 |
+| `cardForeground` | `--card-foreground` | 카드 텍스트 |
+| `popover` | `--popover` | 팝오버 배경 |
| `popoverForeground` | `--popover-foreground` | 팝오버 텍스트 |
#### Semantic Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
-| `primary` | `--primary` | 주요 액션 (CTA 버튼, 링크) |
-| `primaryForeground` | `--primary-foreground` | Primary 위의 텍스트 |
-| `secondary` | `--secondary` | 보조 액션, 비활성 상태 |
-| `secondaryForeground` | `--secondary-foreground` | Secondary 위의 텍스트 |
-| `muted` | `--muted` | 비활성 배경 |
-| `mutedForeground` | `--muted-foreground` | 보조 텍스트, 비활성 텍스트 |
-| `accent` | `--accent` | 강조 요소 (hover, focus) |
-| `accentForeground` | `--accent-foreground` | Accent 위의 텍스트 |
-| `destructive` | `--destructive` | 삭제, 위험 액션 |
-| `destructiveForeground` | `--destructive-foreground` | Destructive 위의 텍스트 |
+| 토큰 | CSS Variable | 용도 |
+| ----------------------- | -------------------------- | -------------------------- |
+| `primary` | `--primary` | 주요 액션 (CTA 버튼, 링크) |
+| `primaryForeground` | `--primary-foreground` | Primary 위의 텍스트 |
+| `secondary` | `--secondary` | 보조 액션, 비활성 상태 |
+| `secondaryForeground` | `--secondary-foreground` | Secondary 위의 텍스트 |
+| `muted` | `--muted` | 비활성 배경 |
+| `mutedForeground` | `--muted-foreground` | 보조 텍스트, 비활성 텍스트 |
+| `accent` | `--accent` | 강조 요소 (hover, focus) |
+| `accentForeground` | `--accent-foreground` | Accent 위의 텍스트 |
+| `destructive` | `--destructive` | 삭제, 위험 액션 |
+| `destructiveForeground` | `--destructive-foreground` | Destructive 위의 텍스트 |
#### UI Element Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
-| `border` | `--border` | 기본 테두리 |
-| `input` | `--input` | 입력 필드 테두리 |
-| `ring` | `--ring` | Focus ring 색상 |
+| 토큰 | CSS Variable | 용도 |
+| -------- | ------------ | ---------------- |
+| `border` | `--border` | 기본 테두리 |
+| `input` | `--input` | 입력 필드 테두리 |
+| `ring` | `--ring` | Focus ring 색상 |
#### Sidebar Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
-| `sidebar` | `--sidebar` | 사이드바 배경 |
-| `sidebarForeground` | `--sidebar-foreground` | 사이드바 텍스트 |
-| `sidebarPrimary` | `--sidebar-primary` | 사이드바 활성 항목 |
+| 토큰 | CSS Variable | 용도 |
+| -------------------------- | ------------------------------ | -------------------- |
+| `sidebar` | `--sidebar` | 사이드바 배경 |
+| `sidebarForeground` | `--sidebar-foreground` | 사이드바 텍스트 |
+| `sidebarPrimary` | `--sidebar-primary` | 사이드바 활성 항목 |
| `sidebarPrimaryForeground` | `--sidebar-primary-foreground` | 사이드바 활성 텍스트 |
-| `sidebarAccent` | `--sidebar-accent` | 사이드바 강조 |
-| `sidebarAccentForeground` | `--sidebar-accent-foreground` | 사이드바 강조 텍스트 |
-| `sidebarBorder` | `--sidebar-border` | 사이드바 테두리 |
-| `sidebarRing` | `--sidebar-ring` | 사이드바 focus ring |
+| `sidebarAccent` | `--sidebar-accent` | 사이드바 강조 |
+| `sidebarAccentForeground` | `--sidebar-accent-foreground` | 사이드바 강조 텍스트 |
+| `sidebarBorder` | `--sidebar-border` | 사이드바 테두리 |
+| `sidebarRing` | `--sidebar-ring` | 사이드바 focus ring |
#### Chart Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
+| 토큰 | CSS Variable | 용도 |
+| ------------------- | ------------------------- | ----------------------- |
| `chart1` ~ `chart5` | `--chart-1` ~ `--chart-5` | 차트/그래프 색상 팔레트 |
#### Main Page Specific Colors
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
-| `mainBg` | `--main-bg` | 메인 페이지 배경 |
-| `mainCtaBg` | `--main-cta-bg` | 메인 페이지 CTA 배경 |
-| `mainAccent` | `--main-accent` | 메인 페이지 강조 색상 |
+| 토큰 | CSS Variable | 용도 |
+| --------------- | ------------------- | ----------------------- |
+| `mainBg` | `--main-bg` | 메인 페이지 배경 |
+| `mainCtaBg` | `--main-cta-bg` | 메인 페이지 CTA 배경 |
+| `mainAccent` | `--main-accent` | 메인 페이지 강조 색상 |
| `mainTextWhite` | `--main-text-white` | 메인 페이지 흰색 텍스트 |
-| `mainTextGray` | `--main-text-gray` | 메인 페이지 회색 텍스트 |
+| `mainTextGray` | `--main-text-gray` | 메인 페이지 회색 텍스트 |
+
+#### Magazine Palette (Editorial surfaces, fixed dark)
+
+Editorial 표면 (home, magazine, spot detail) 전용. `next-themes`의 `.dark` 토글과 무관하게 항상 고정된 hex 값을 사용한다. Product UI에서는 사용하지 않는다 ([patterns.md §5.4](./patterns.md) 참조).
+
+| 토큰 | CSS Variable | Hex 값 | 용도 |
+| ------------ | --------------- | --------- | --------------------------------- |
+| `magPrimary` | `--mag-primary` | `#050505` | Editorial primary (near-black) |
+| `magAccent` | `--mag-accent` | `#eafd67` | Editorial accent (lime highlight) |
+| `magBg` | `--mag-bg` | `#050505` | Editorial background |
+| `magText` | `--mag-text` | `#f5f5f5` | Editorial text (off-white) |
+
+코드 참조: `packages/web/lib/design-system/tokens.ts:176-179`, `packages/web/app/globals.css:109-112` (#573).
**사용 예시**:
@@ -189,35 +202,35 @@ CSS 변수는 `next-themes`와 함께 작동하여 자동으로 테마 전환됩
4px 기준 단위를 사용하는 스페이싱 스케일:
-| 토큰 | 값 | Tailwind | 용도 |
-|------|----|----|------|
-| `0` | 0px | `p-0`, `m-0` | 여백 제거 |
-| `0.5` | 2px | `p-0.5`, `m-0.5` | 아주 작은 여백 |
-| `1` | 4px | `p-1`, `m-1` | 최소 여백 |
-| `1.5` | 6px | `p-1.5`, `m-1.5` | 작은 여백 |
-| `2` | 8px | `p-2`, `m-2` | 작은 여백 |
-| `2.5` | 10px | `p-2.5`, `m-2.5` | 중간-작은 여백 |
-| `3` | 12px | `p-3`, `m-3` | 중간 여백 |
-| `4` | 16px | `p-4`, `m-4` | 기본 여백 |
-| `5` | 20px | `p-5`, `m-5` | 중간-큰 여백 |
-| `6` | 24px | `p-6`, `m-6` | 큰 여백 |
-| `8` | 32px | `p-8`, `m-8` | 매우 큰 여백 |
-| `10` | 40px | `p-10`, `m-10` | 섹션 여백 |
-| `12` | 48px | `p-12`, `m-12` | 큰 섹션 여백 |
-| `16` | 64px | `p-16`, `m-16` | 페이지 여백 |
-| `20` | 80px | `p-20`, `m-20` | 큰 페이지 여백 |
-| `24` | 96px | `p-24`, `m-24` | Hero 여백 |
-| `32` | 128px | `p-32`, `m-32` | 최대 여백 |
+| 토큰 | 값 | Tailwind | 용도 |
+| ----- | ----- | ---------------- | -------------- |
+| `0` | 0px | `p-0`, `m-0` | 여백 제거 |
+| `0.5` | 2px | `p-0.5`, `m-0.5` | 아주 작은 여백 |
+| `1` | 4px | `p-1`, `m-1` | 최소 여백 |
+| `1.5` | 6px | `p-1.5`, `m-1.5` | 작은 여백 |
+| `2` | 8px | `p-2`, `m-2` | 작은 여백 |
+| `2.5` | 10px | `p-2.5`, `m-2.5` | 중간-작은 여백 |
+| `3` | 12px | `p-3`, `m-3` | 중간 여백 |
+| `4` | 16px | `p-4`, `m-4` | 기본 여백 |
+| `5` | 20px | `p-5`, `m-5` | 중간-큰 여백 |
+| `6` | 24px | `p-6`, `m-6` | 큰 여백 |
+| `8` | 32px | `p-8`, `m-8` | 매우 큰 여백 |
+| `10` | 40px | `p-10`, `m-10` | 섹션 여백 |
+| `12` | 48px | `p-12`, `m-12` | 큰 섹션 여백 |
+| `16` | 64px | `p-16`, `m-16` | 페이지 여백 |
+| `20` | 80px | `p-20`, `m-20` | 큰 페이지 여백 |
+| `24` | 96px | `p-24`, `m-24` | Hero 여백 |
+| `32` | 128px | `p-32`, `m-32` | 최대 여백 |
### 권장 사용 패턴
-| 컨텍스트 | 권장 스케일 | 예시 |
-|----------|-------------|------|
-| 컴포넌트 내부 패딩 | `2` ~ `4` (8-16px) | 버튼, 카드 내부 |
-| 컴포넌트 간 간격 | `4` ~ `6` (16-24px) | 카드 그리드 gap |
-| 섹션 간 간격 | `6` ~ `8` (24-32px) | 페이지 섹션 구분 |
-| 페이지 패딩 | `4` ~ `16` (16-64px) | 페이지 좌우 여백 (반응형) |
-| Hero 섹션 | `10` ~ `24` (40-96px) | Hero 상하 여백 |
+| 컨텍스트 | 권장 스케일 | 예시 |
+| ------------------ | --------------------- | ------------------------- |
+| 컴포넌트 내부 패딩 | `2` ~ `4` (8-16px) | 버튼, 카드 내부 |
+| 컴포넌트 간 간격 | `4` ~ `6` (16-24px) | 카드 그리드 gap |
+| 섹션 간 간격 | `6` ~ `8` (24-32px) | 페이지 섹션 구분 |
+| 페이지 패딩 | `4` ~ `16` (16-64px) | 페이지 좌우 여백 (반응형) |
+| Hero 섹션 | `10` ~ `24` (40-96px) | Hero 상하 여백 |
**사용 예시**:
@@ -254,13 +267,13 @@ CSS 변수는 `next-themes`와 함께 작동하여 자동으로 테마 전환됩
표준 Tailwind 브레이크포인트:
-| 이름 | 값 | Tailwind Prefix | 용도 |
-|------|----|----|------|
-| `sm` | 640px | `sm:` | 작은 태블릿 이상 |
-| `md` | 768px | `md:` | 태블릿 이상 (Desktop Header 표시) |
-| `lg` | 1024px | `lg:` | 데스크탑 이상 |
-| `xl` | 1280px | `xl:` | 큰 데스크탑 |
-| `2xl` | 1536px | `2xl:` | 초대형 화면 |
+| 이름 | 값 | Tailwind Prefix | 용도 |
+| ----- | ------ | --------------- | --------------------------------- |
+| `sm` | 640px | `sm:` | 작은 태블릿 이상 |
+| `md` | 768px | `md:` | 태블릿 이상 (Desktop Header 표시) |
+| `lg` | 1024px | `lg:` | 데스크탑 이상 |
+| `xl` | 1280px | `xl:` | 큰 데스크탑 |
+| `2xl` | 1536px | `2xl:` | 초대형 화면 |
**Mobile First 접근**:
@@ -283,15 +296,15 @@ CSS 변수는 `next-themes`와 함께 작동하여 자동으로 테마 전환됩
CSS 변수 기반 그림자 스케일 (`globals.css`에서 정의):
-| 토큰 | CSS Variable | 용도 |
-|------|--------------|------|
+| 토큰 | CSS Variable | 용도 |
+| ----- | -------------- | ------------------------------ |
| `2xs` | `--shadow-2xs` | 아주 작은 그림자 (미묘한 구분) |
-| `xs` | `--shadow-xs` | 작은 그림자 (카드 기본) |
-| `sm` | `--shadow-sm` | 작은-중간 그림자 |
-| `md` | `--shadow-md` | 중간 그림자 (Elevated 카드) |
-| `lg` | `--shadow-lg` | 큰 그림자 (Hover 상태) |
-| `xl` | `--shadow-xl` | 매우 큰 그림자 (모달) |
-| `2xl` | `--shadow-2xl` | 최대 그림자 (드롭다운) |
+| `xs` | `--shadow-xs` | 작은 그림자 (카드 기본) |
+| `sm` | `--shadow-sm` | 작은-중간 그림자 |
+| `md` | `--shadow-md` | 중간 그림자 (Elevated 카드) |
+| `lg` | `--shadow-lg` | 큰 그림자 (Hover 상태) |
+| `xl` | `--shadow-xl` | 매우 큰 그림자 (모달) |
+| `2xl` | `--shadow-2xl` | 최대 그림자 (드롭다운) |
**사용 예시**:
@@ -321,16 +334,16 @@ const styles = {
CSS 변수 `--radius`를 기준으로 계산되는 둥근 모서리:
-| 토큰 | 계산식 | 용도 |
-|------|-------|------|
-| `none` | 0 | 둥근 모서리 제거 |
-| `sm` | `calc(var(--radius) - 4px)` | 작은 모서리 (버튼 내부 요소) |
-| `md` | `var(--radius)` | 기본 모서리 (카드, 버튼) |
-| `lg` | `calc(var(--radius) + 4px)` | 큰 모서리 |
-| `xl` | `calc(var(--radius) + 8px)` | 매우 큰 모서리 |
-| `2xl` | `calc(var(--radius) + 12px)` | 최대 모서리 |
-| `full` | 9999px | 완전한 원형 (아바타, 뱃지) |
-| `main` | `var(--main-border-radius)` | 메인 페이지 전용 |
+| 토큰 | 계산식 | 용도 |
+| ------ | ---------------------------- | ---------------------------- |
+| `none` | 0 | 둥근 모서리 제거 |
+| `sm` | `calc(var(--radius) - 4px)` | 작은 모서리 (버튼 내부 요소) |
+| `md` | `var(--radius)` | 기본 모서리 (카드, 버튼) |
+| `lg` | `calc(var(--radius) + 4px)` | 큰 모서리 |
+| `xl` | `calc(var(--radius) + 8px)` | 매우 큰 모서리 |
+| `2xl` | `calc(var(--radius) + 12px)` | 최대 모서리 |
+| `full` | 9999px | 완전한 원형 (아바타, 뱃지) |
+| `main` | `var(--main-border-radius)` | 메인 페이지 전용 |
**사용 예시**:
@@ -359,17 +372,17 @@ import { borderRadius } from "@/lib/design-system";
일관된 레이어 순서를 위한 z-index 스케일:
-| 토큰 | 값 | 용도 |
-|------|----|----|
-| `base` | 0 | 기본 레이어 |
-| `floating` | 10 | Floating 요소 (카드 hover) |
-| `dropdown` | 20 | 드롭다운 메뉴 |
-| `header` | 30 | Sticky header/footer |
-| `sidebar` | 40 | Sidebar (모바일) |
-| `modalBackdrop` | 50 | 모달 배경 (dim) |
-| `modal` | 60 | 모달 콘텐츠 |
-| `toast` | 70 | 토스트 알림 |
-| `tooltip` | 100 | 툴팁 (최상위) |
+| 토큰 | 값 | 용도 |
+| --------------- | --- | -------------------------- |
+| `base` | 0 | 기본 레이어 |
+| `floating` | 10 | Floating 요소 (카드 hover) |
+| `dropdown` | 20 | 드롭다운 메뉴 |
+| `header` | 30 | Sticky header/footer |
+| `sidebar` | 40 | Sidebar (모바일) |
+| `modalBackdrop` | 50 | 모달 배경 (dim) |
+| `modal` | 60 | 모달 콘텐츠 |
+| `toast` | 70 | 토스트 알림 |
+| `tooltip` | 100 | 툴팁 (최상위) |
**사용 예시**:
@@ -403,7 +416,15 @@ const headerStyles = {
```tsx
// 전체 import
-import { typography, colors, spacing, breakpoints, shadows, borderRadius, zIndex } from "@/lib/design-system";
+import {
+ typography,
+ colors,
+ spacing,
+ breakpoints,
+ shadows,
+ borderRadius,
+ zIndex,
+} from "@/lib/design-system";
// 특정 토큰만 import
import { colors, spacing } from "@/lib/design-system";
@@ -412,7 +433,11 @@ import { colors, spacing } from "@/lib/design-system";
### 2. TypeScript 타입 활용
```tsx
-import type { ColorToken, SpacingToken, ShadowToken } from "@/lib/design-system";
+import type {
+ ColorToken,
+ SpacingToken,
+ ShadowToken,
+} from "@/lib/design-system";
// 타입 안전한 props
interface ComponentProps {
diff --git a/docs/superpowers/plans/2026-05-14-content-studio-video-jobs.md b/docs/superpowers/plans/2026-05-14-content-studio-video-jobs.md
new file mode 100644
index 00000000..aabeb4ad
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-14-content-studio-video-jobs.md
@@ -0,0 +1,2159 @@
+# Content Studio Video Jobs Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Promote the Phase 1A xAI scene video flow from one long synchronous API request into a resumable Content Studio video job pipeline with per-scene status, retry, and permanent Supabase Storage URLs.
+
+**Architecture:** Keep the existing synchronous `POST /api/v1/content/assets/videos` route as the Phase 1A compatibility path. Add a new job-backed path: create a job and xAI scene requests, persist `request_id`s, let the UI poll a short route that checks current xAI state once per scene, stores completed clips, and returns the updated job. This avoids introducing a background worker in Phase 1B while making refresh/resume/retry safe.
+
+**Tech Stack:** Next.js 16 App Router route handlers, TypeScript, Zod, Supabase Postgres + Storage via service-role server client, xAI Grok Imagine Video REST API, Vitest, React Testing Library.
+
+---
+
+## Human Checkpoint
+
+This plan includes a Supabase migration. Do not edit `supabase/migrations/` until the human explicitly approves this infra checkpoint for Issue #508 Phase 1B.
+
+Checkpoint wording:
+
+```text
+Approve infra checkpoint for Issue #508 Phase 1B:
+- create content_studio_video_jobs
+- create content_studio_video_scenes
+- enable RLS with no direct client policies; API routes use admin-gated service_role writes
+```
+
+---
+
+## File Structure
+
+- Create: `supabase/migrations/20260514120000_content_studio_video_jobs.sql`
+ - Owns persistent job and scene state for short-form video generation.
+ - Stores plan/request snapshots as JSONB so Phase 1B does not depend on broader Content Studio DB persistence.
+
+- Modify: `packages/web/lib/supabase/types.ts`
+ - Regenerates Supabase table types after the local migration applies so service-role table access typechecks.
+
+- Modify: `packages/web/lib/content-studio/schemas.ts`
+ - Adds job status schemas and DTOs returned to the UI.
+
+- Modify: `packages/web/lib/content-studio/assets/video.ts`
+ - Exports low-level xAI start/poll-once/download/store helpers so the job service can reuse the Phase 1A implementation without turning pending scenes into failures.
+ - Keeps `generateShortFormVideos()` behavior unchanged for compatibility.
+
+- Create: `packages/web/lib/content-studio/assets/video-jobs.ts`
+ - Creates jobs, starts scene requests, polls scenes, persists updates, and retries scenes.
+
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/route.ts`
+ - `POST` creates a video job and starts xAI requests.
+
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/route.ts`
+ - `GET` returns the persisted job for resume.
+
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts`
+ - `POST` polls non-terminal scenes once and stores completed clips.
+
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts`
+ - `POST` restarts one failed/expired scene.
+
+- Modify: `packages/web/app/admin/content-studio/ShortFormPanel.tsx`
+ - Switches UI from the synchronous `/videos` route to job create + polling.
+ - Shows resumable job state, scene retry buttons, video previews, and download links.
+
+- Modify: `docs/agent/api-v1-routes.md`
+ - Adds the new job routes.
+
+- Test: `packages/web/lib/content-studio/__tests__/video.test.ts`
+ - Keeps existing Phase 1A tests and verifies exported helper behavior does not regress.
+
+- Create: `packages/web/lib/content-studio/__tests__/video-jobs.test.ts`
+ - Covers create, one-shot pending poll, complete/upload, failed/expired scene update, and retry.
+
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts`
+ - Covers auth, invalid body, create success, get success, poll success, retry success.
+
+- Modify: `packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx`
+ - Verifies UI creates a job, polls it, renders scene status, and renders completed video preview/link.
+
+---
+
+### Task 1: Infra Checkpoint and Migration
+
+**Files:**
+- Create: `supabase/migrations/20260514120000_content_studio_video_jobs.sql`
+- Modify: `packages/web/lib/supabase/types.ts`
+
+- [ ] **Step 1: Get explicit human approval**
+
+Required approval text:
+
+```text
+Approve infra checkpoint for Issue #508 Phase 1B.
+```
+
+Expected: Human approval is present in the conversation before editing migration files.
+
+- [ ] **Step 2: Create migration**
+
+Create `supabase/migrations/20260514120000_content_studio_video_jobs.sql`:
+
+```sql
+-- operation: create Content Studio video job persistence
+--
+-- Why: Issue #508 Phase 1B needs xAI scene video generation to survive refreshes,
+-- support per-scene retry, and avoid one long synchronous API request.
+-- The UI still remains operator/admin-only.
+--
+-- Access model:
+-- - Route handlers verify admin status with cookie session.
+-- - Route handlers use service_role for table writes and Storage upload.
+-- - RLS is enabled with no direct client policies; service_role bypasses RLS.
+
+CREATE TABLE IF NOT EXISTS public.content_studio_video_jobs (
+ id text PRIMARY KEY,
+ plan_id text NOT NULL,
+ packet_id text,
+ status text NOT NULL CHECK (status IN ('pending', 'running', 'partial', 'done', 'failed', 'expired')),
+ provider text NOT NULL DEFAULT 'xai' CHECK (provider = 'xai'),
+ model text NOT NULL,
+ request jsonb NOT NULL,
+ plan jsonb NOT NULL,
+ packet jsonb,
+ error text,
+ created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ completed_at timestamptz
+);
+
+CREATE TABLE IF NOT EXISTS public.content_studio_video_scenes (
+ id text PRIMARY KEY,
+ job_id text NOT NULL REFERENCES public.content_studio_video_jobs(id) ON DELETE CASCADE,
+ plan_id text NOT NULL,
+ scene_id text NOT NULL,
+ scene_order integer NOT NULL,
+ status text NOT NULL CHECK (status IN ('pending', 'running', 'done', 'expired', 'failed')),
+ provider text NOT NULL DEFAULT 'xai' CHECK (provider = 'xai'),
+ model text NOT NULL,
+ request_id text,
+ prompt text NOT NULL,
+ duration_seconds integer NOT NULL CHECK (duration_seconds BETWEEN 1 AND 15),
+ aspect_ratio text NOT NULL,
+ resolution text NOT NULL,
+ temporary_url text,
+ permanent_url text,
+ storage_path text,
+ error text,
+ started_at timestamptz,
+ completed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (job_id, scene_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_content_studio_video_jobs_plan_id
+ ON public.content_studio_video_jobs(plan_id);
+
+CREATE INDEX IF NOT EXISTS idx_content_studio_video_jobs_status_updated
+ ON public.content_studio_video_jobs(status, updated_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_content_studio_video_scenes_job_order
+ ON public.content_studio_video_scenes(job_id, scene_order);
+
+ALTER TABLE public.content_studio_video_jobs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.content_studio_video_scenes ENABLE ROW LEVEL SECURITY;
+```
+
+- [ ] **Step 3: Verify migration applies locally**
+
+Run:
+
+```bash
+supabase db reset
+```
+
+Expected: Migration succeeds and local seed completes.
+
+- [ ] **Step 4: Verify tables exist**
+
+Run:
+
+```bash
+psql postgresql://postgres:postgres@127.0.0.1:54322/postgres -tAc "select table_name from information_schema.tables where table_schema='public' and table_name in ('content_studio_video_jobs','content_studio_video_scenes') order by table_name;"
+```
+
+Expected:
+
+```text
+content_studio_video_jobs
+content_studio_video_scenes
+```
+
+- [ ] **Step 5: Regenerate Supabase types**
+
+Run:
+
+```bash
+supabase gen types typescript --local --schema public > packages/web/lib/supabase/types.ts
+```
+
+Expected: `packages/web/lib/supabase/types.ts` includes `content_studio_video_jobs` and `content_studio_video_scenes`.
+
+- [ ] **Step 6: Commit migration and regenerated Supabase types**
+
+Run:
+
+```bash
+git add supabase/migrations/20260514120000_content_studio_video_jobs.sql packages/web/lib/supabase/types.ts
+git commit -m "feat(content-studio): add video job persistence tables"
+```
+
+Expected: Commit succeeds.
+
+---
+
+### Task 2: Schemas and xAI Helper Exports
+
+**Files:**
+- Modify: `packages/web/lib/content-studio/schemas.ts`
+- Modify: `packages/web/lib/content-studio/assets/video.ts`
+- Modify: `packages/web/lib/content-studio/__tests__/assets.test.ts`
+- Modify: `packages/web/lib/content-studio/__tests__/video.test.ts`
+
+- [ ] **Step 1: Add failing schema tests**
+
+In `packages/web/lib/content-studio/__tests__/assets.test.ts`, extend imports:
+
+```ts
+import {
+ assetPlanRequestSchema,
+ assetPlanSchema,
+ shortFormVideoJobSchema,
+ shortFormVideoJobStatusSchema,
+ shortFormVideoRequestSchema,
+ shortFormVideoResolutionSchema,
+ shortFormVideoStatusSchema,
+} from "../schemas";
+```
+
+Add:
+
+```ts
+it("parses a persisted short-form video job with scene statuses", () => {
+ const parsed = shortFormVideoJobSchema.parse({
+ id: "video_job_short_form_packet-1_1700000000000",
+ planId: "short_form_packet-1",
+ packetId: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: {
+ useReferenceImage: true,
+ resolution: "480p",
+ },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ scenes: [
+ {
+ id: "video_job_short_form_packet-1_1700000000000_scene_1",
+ jobId: "video_job_short_form_packet-1_1700000000000",
+ planId: "short_form_packet-1",
+ sceneId: "scene_1",
+ sceneOrder: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ requestId: "req-scene-1",
+ prompt: "Scene prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ temporaryUrl: null,
+ permanentUrl: null,
+ storagePath: null,
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ },
+ ],
+ });
+
+ expect(parsed.status).toBe("running");
+ expect(parsed.scenes[0].requestId).toBe("req-scene-1");
+});
+
+it("rejects unknown short-form video job statuses", () => {
+ expect(shortFormVideoJobStatusSchema.safeParse("queued_forever").success).toBe(
+ false
+ );
+});
+
+it("allows running scene video status for persisted jobs", () => {
+ expect(shortFormVideoStatusSchema.parse("running")).toBe("running");
+});
+```
+
+- [ ] **Step 2: Run tests to verify failure**
+
+Run:
+
+```bash
+bun test packages/web/lib/content-studio/__tests__/assets.test.ts
+```
+
+Expected: FAIL because `shortFormVideoJobSchema` and `shortFormVideoJobStatusSchema` are not exported yet and `shortFormVideoStatusSchema` does not allow `running`.
+
+- [ ] **Step 3: Add schemas**
+
+In `packages/web/lib/content-studio/schemas.ts`, update `shortFormVideoStatusSchema` so persisted job scenes can represent in-flight xAI work:
+
+```ts
+export const shortFormVideoStatusSchema = z.enum([
+ "pending",
+ "running",
+ "done",
+ "expired",
+ "failed",
+]);
+```
+
+Then after `shortFormVideoStatusSchema`, add:
+
+```ts
+export const shortFormVideoJobStatusSchema = z.enum([
+ "pending",
+ "running",
+ "partial",
+ "done",
+ "failed",
+ "expired",
+]);
+```
+
+After `shortFormVideoAssetSchema`, add:
+
+```ts
+export const shortFormVideoJobSceneSchema = shortFormVideoAssetSchema
+ .omit({ id: true })
+ .extend({
+ id: z.string(),
+ jobId: z.string(),
+ startedAt: z.string().nullable(),
+ completedAt: z.string().nullable(),
+ });
+
+export const shortFormVideoJobSchema = z.object({
+ id: z.string(),
+ planId: z.string(),
+ packetId: z.string().nullable(),
+ status: shortFormVideoJobStatusSchema,
+ provider: z.literal("xai"),
+ model: z.string(),
+ request: z.record(z.string(), z.unknown()),
+ error: z.string().nullable(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ completedAt: z.string().nullable(),
+ scenes: z.array(shortFormVideoJobSceneSchema),
+});
+```
+
+Near type exports, add:
+
+```ts
+export type ShortFormVideoJobStatus = z.infer<
+ typeof shortFormVideoJobStatusSchema
+>;
+export type ShortFormVideoJobScene = z.infer<
+ typeof shortFormVideoJobSceneSchema
+>;
+export type ShortFormVideoJob = z.infer;
+```
+
+- [ ] **Step 4: Export low-level helpers from video service**
+
+In `packages/web/lib/content-studio/assets/video.ts`, rename the private `XaiPollResponse` type to exported `XaiVideoStatusResponse`, export the existing helpers, and add a one-shot status helper for job polling:
+
+```ts
+export type XaiVideoStatusResponse = {
+ status?: "pending" | "done" | "expired" | "failed";
+ video?: {
+ url?: string;
+ duration?: number;
+ };
+ error?: {
+ message?: string;
+ };
+};
+
+export function resolveShortFormVideoAspectRatio(
+ input: ShortFormVideoRequest
+): ShortFormVideoAspectRatio {
+ if (input.aspectRatio) return input.aspectRatio;
+ return input.plan.platform === "instagram_reel" ? "9:16" : "9:16";
+}
+
+export function buildShortFormSceneVideoPrompt(
+ plan: ShortFormPlan,
+ scene: ShortFormPlan["scenes"][0]
+) {
+ return [
+ plan.hook,
+ `Scene ${scene.order}: ${scene.visualDirection}`,
+ `On-screen text direction: ${scene.onScreenText}`,
+ `Narration mood: ${scene.narration}`,
+ "Editorial fashion short-form clip, clean camera movement, realistic lighting, no extra text overlays unless explicitly requested.",
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+export async function startXaiVideoGeneration(input: {
+ apiKey: string;
+ model: string;
+ prompt: string;
+ durationSeconds: number;
+ aspectRatio: ShortFormVideoAspectRatio;
+ resolution: ShortFormVideoResolution;
+ imageUrl?: string;
+}): Promise {
+ // existing body unchanged
+}
+
+export async function pollXaiVideo(input: {
+ apiKey: string;
+ requestId: string;
+ pollIntervalMs: number;
+ timeoutMs: number;
+}): Promise {
+ // existing body unchanged
+}
+
+export async function getXaiVideoStatusOnce(input: {
+ apiKey: string;
+ requestId: string;
+}): Promise {
+ const response = await fetch(`${XAI_VIDEOS_URL}/${input.requestId}`, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${input.apiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ `xAI video polling failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}`
+ );
+ }
+
+ return (await response.json()) as XaiVideoStatusResponse;
+}
+
+export async function fetchVideoBytes(
+ url: string
+): Promise<{ bytes: Buffer; contentType: string }> {
+ // existing body unchanged
+}
+
+export async function uploadVideoToStorage(input: {
+ planId: string;
+ sceneId: string;
+ requestId: string;
+ bytes: Buffer;
+ contentType: string;
+}): Promise<{ publicUrl: string; path: string }> {
+ // existing body unchanged
+}
+
+export function referenceImageForScene(
+ packet: ContentPacket | undefined,
+ useReferenceImage: boolean
+): string | undefined {
+ // existing body unchanged
+}
+```
+
+Update internal calls:
+
+```ts
+const aspectRatio = resolveShortFormVideoAspectRatio(input);
+const prompt = buildShortFormSceneVideoPrompt(input.plan, scene);
+```
+
+- [ ] **Step 5: Run tests**
+
+Run:
+
+```bash
+bun test packages/web/lib/content-studio/__tests__/assets.test.ts packages/web/lib/content-studio/__tests__/video.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+Run:
+
+```bash
+git add packages/web/lib/content-studio/schemas.ts packages/web/lib/content-studio/assets/video.ts packages/web/lib/content-studio/__tests__/assets.test.ts packages/web/lib/content-studio/__tests__/video.test.ts
+git commit -m "feat(content-studio): add video job schemas"
+```
+
+Expected: Commit succeeds.
+
+---
+
+### Task 3: Video Job Persistence Service
+
+**Files:**
+- Create: `packages/web/lib/content-studio/assets/video-jobs.ts`
+- Create: `packages/web/lib/content-studio/__tests__/video-jobs.test.ts`
+- Modify: `packages/web/lib/content-studio/assets/index.ts`
+
+- [ ] **Step 1: Write failing service tests**
+
+Create `packages/web/lib/content-studio/__tests__/video-jobs.test.ts`:
+
+```ts
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ContentPacket } from "../schemas";
+import { buildShortFormPlan } from "../assets";
+import {
+ createShortFormVideoJob,
+ pollShortFormVideoJob,
+ retryShortFormVideoJobScene,
+} from "../assets/video-jobs";
+
+const rows = {
+ jobs: [] as Array>,
+ scenes: [] as Array>,
+};
+
+function table(name: "content_studio_video_jobs" | "content_studio_video_scenes") {
+ const target = name === "content_studio_video_jobs" ? rows.jobs : rows.scenes;
+ return {
+ insert: vi.fn(async (value: unknown) => {
+ const items = Array.isArray(value) ? value : [value];
+ target.push(...(items as Array>));
+ return { error: null };
+ }),
+ update: vi.fn((patch: Record) => ({
+ eq: vi.fn(async (column: string, value: unknown) => {
+ for (const item of target) {
+ if (item[column] === value) Object.assign(item, patch);
+ }
+ return { error: null };
+ }),
+ })),
+ select: vi.fn(() => ({
+ eq: vi.fn((column: string, value: unknown) => ({
+ order: vi.fn(async () => ({
+ data: target.filter((item) => item[column] === value),
+ error: null,
+ })),
+ maybeSingle: vi.fn(async () => ({
+ data: target.find((item) => item[column] === value) ?? null,
+ error: null,
+ })),
+ })),
+ })),
+ };
+}
+
+vi.mock("@/lib/supabase/admin-server", () => ({
+ createAdminSupabaseClient: () => ({
+ from: (name: "content_studio_video_jobs" | "content_studio_video_scenes") =>
+ table(name),
+ storage: {
+ from: () => ({
+ upload: vi.fn(async () => ({ error: null })),
+ getPublicUrl: (path: string) => ({
+ data: { publicUrl: `https://storage.example.com/${path}` },
+ }),
+ }),
+ },
+ }),
+}));
+
+const packet: ContentPacket = {
+ id: "packet-1",
+ postId: "post-1",
+ sourceImage: "https://example.com/source.jpg",
+ title: "Airport denim layered look",
+ hook: "Why this airport look reads premium",
+ artist: null,
+ group: null,
+ context: null,
+ detectedItems: [],
+ styleSummary: "Layered denim with structured outerwear keeps proportion crisp.",
+ whyItWorks: "The silhouette does the work before the brand does.",
+ alternatives: { budget: [], mid: [], premium: [] },
+ disclosureFlags: {
+ aiGenerated: false,
+ syntheticMedia: false,
+ sponsored: false,
+ rightsRisk: false,
+ },
+ riskLevel: "low",
+ reviewStatus: "draft",
+ createdAt: "2026-05-07T00:00:00.000Z",
+};
+
+function restoreEnv(name: string, value: string | undefined) {
+ if (value === undefined) delete process.env[name];
+ else process.env[name] = value;
+}
+
+describe("short-form video jobs", () => {
+ afterEach(() => {
+ rows.jobs = [];
+ rows.scenes = [];
+ vi.restoreAllMocks();
+ });
+
+ it("creates a persisted job and stores xAI request ids per scene", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000);
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "req-1" }), { status: 200 }))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "req-2" }), { status: 200 }));
+
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ useResearchInCopy: false,
+ variants: [],
+ });
+
+ try {
+ const job = await createShortFormVideoJob({
+ plan,
+ packet,
+ useReferenceImage: true,
+ resolution: "480p",
+ createdBy: "33333333-3333-4333-8333-333333333333",
+ });
+
+ expect(job.id).toBe("video_job_short_form_packet-1_1700000000000");
+ expect(job.status).toBe("running");
+ expect(job.scenes.map((scene) => scene.requestId)).toEqual(["req-1", "req-2"]);
+ expect(rows.jobs).toHaveLength(1);
+ expect(rows.scenes).toHaveLength(2);
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("polls a running scene once and stores completed video output", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-1",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ status: "done", video: { url: "https://vidgen.x.ai/video.mp4", duration: 8 } }), { status: 200 })
+ )
+ .mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "Content-Type": "video/mp4" } }));
+
+ try {
+ const job = await pollShortFormVideoJob("job-1");
+ expect(job.status).toBe("done");
+ expect(job.scenes[0].status).toBe("done");
+ expect(job.scenes[0].permanentUrl).toContain("https://storage.example.com/plan-1/videos/scene-1_req-1.mp4");
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("keeps a running scene running when xAI status is still pending", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-1",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(JSON.stringify({ status: "pending" }), { status: 200 })
+ );
+
+ try {
+ const job = await pollShortFormVideoJob("job-1");
+ expect(job.status).toBe("running");
+ expect(job.scenes[0].status).toBe("running");
+ expect(job.scenes[0].error).toBeNull();
+ expect(job.scenes[0].completedAt).toBeNull();
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("retries a failed scene by starting a fresh xAI request", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "partial",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "failed",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-old",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: "previous failure",
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-new" }), { status: 200 })
+ );
+
+ try {
+ const job = await retryShortFormVideoJobScene("job-1", "scene-1");
+ expect(job.status).toBe("running");
+ expect(job.scenes[0].status).toBe("running");
+ expect(job.scenes[0].requestId).toBe("req-new");
+ expect(job.scenes[0].error).toBeNull();
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify failure**
+
+Run:
+
+```bash
+bun test packages/web/lib/content-studio/__tests__/video-jobs.test.ts
+```
+
+Expected: FAIL because `assets/video-jobs.ts` does not exist.
+
+- [ ] **Step 3: Implement service**
+
+Create `packages/web/lib/content-studio/assets/video-jobs.ts`:
+
+```ts
+import { createAdminSupabaseClient } from "@/lib/supabase/admin-server";
+import type {
+ ContentPacket,
+ ShortFormPlan,
+ ShortFormVideoAspectRatio,
+ ShortFormVideoJob,
+ ShortFormVideoJobScene,
+ ShortFormVideoJobStatus,
+ ShortFormVideoRequest,
+ ShortFormVideoResolution,
+ ShortFormVideoStatus,
+} from "../schemas";
+import { shortFormVideoJobSchema } from "../schemas";
+import {
+ buildShortFormSceneVideoPrompt,
+ contentStudioVideoModel,
+ fetchVideoBytes,
+ getXaiVideoStatusOnce,
+ referenceImageForScene,
+ resolveShortFormVideoAspectRatio,
+ startXaiVideoGeneration,
+ uploadVideoToStorage,
+} from "./video";
+
+type JobRow = {
+ id: string;
+ plan_id: string;
+ packet_id: string | null;
+ status: ShortFormVideoJobStatus;
+ provider: "xai";
+ model: string;
+ request: Record;
+ plan: unknown;
+ packet: unknown | null;
+ error: string | null;
+ created_at: string;
+ updated_at: string;
+ completed_at: string | null;
+};
+
+type SceneRow = {
+ id: string;
+ job_id: string;
+ plan_id: string;
+ scene_id: string;
+ scene_order: number;
+ status: ShortFormVideoStatus;
+ provider: "xai";
+ model: string;
+ request_id: string | null;
+ prompt: string;
+ duration_seconds: number;
+ aspect_ratio: ShortFormVideoAspectRatio;
+ resolution: ShortFormVideoResolution;
+ temporary_url: string | null;
+ permanent_url: string | null;
+ storage_path: string | null;
+ error: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+};
+
+export type CreateShortFormVideoJobInput = ShortFormVideoRequest & {
+ createdBy: string;
+};
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function jobIdForPlan(planId: string) {
+ return `video_job_${planId}_${Date.now()}`;
+}
+
+function sceneRowToDto(row: SceneRow): ShortFormVideoJobScene {
+ return {
+ id: row.id,
+ jobId: row.job_id,
+ planId: row.plan_id,
+ sceneId: row.scene_id,
+ sceneOrder: row.scene_order,
+ status: row.status,
+ provider: row.provider,
+ model: row.model,
+ requestId: row.request_id,
+ prompt: row.prompt,
+ durationSeconds: row.duration_seconds,
+ aspectRatio: row.aspect_ratio,
+ resolution: row.resolution,
+ temporaryUrl: row.temporary_url,
+ permanentUrl: row.permanent_url,
+ storagePath: row.storage_path,
+ error: row.error,
+ startedAt: row.started_at,
+ completedAt: row.completed_at,
+ };
+}
+
+function jobRowsToDto(job: JobRow, scenes: SceneRow[]): ShortFormVideoJob {
+ return shortFormVideoJobSchema.parse({
+ id: job.id,
+ planId: job.plan_id,
+ packetId: job.packet_id,
+ status: job.status,
+ provider: job.provider,
+ model: job.model,
+ request: job.request,
+ error: job.error,
+ createdAt: job.created_at,
+ updatedAt: job.updated_at,
+ completedAt: job.completed_at,
+ scenes: scenes
+ .sort((a, b) => a.scene_order - b.scene_order)
+ .map(sceneRowToDto),
+ });
+}
+
+function statusForScenes(scenes: SceneRow[]): ShortFormVideoJobStatus {
+ if (scenes.length === 0) return "failed";
+ if (scenes.every((scene) => scene.status === "done")) return "done";
+ if (scenes.every((scene) => scene.status === "expired")) return "expired";
+ if (scenes.every((scene) => scene.status === "failed")) return "failed";
+ if (scenes.some((scene) => scene.status === "running" || scene.status === "pending")) {
+ return "running";
+ }
+ return "partial";
+}
+
+async function loadJob(jobId: string): Promise<{ job: JobRow; scenes: SceneRow[] }> {
+ const supabase = createAdminSupabaseClient();
+ const { data: job, error: jobError } = await supabase
+ .from("content_studio_video_jobs")
+ .select("*")
+ .eq("id", jobId)
+ .maybeSingle();
+
+ if (jobError) throw new Error(`Video job load failed: ${jobError.message}`);
+ if (!job) throw new Error("Video job not found");
+
+ const { data: scenes, error: scenesError } = await supabase
+ .from("content_studio_video_scenes")
+ .select("*")
+ .eq("job_id", jobId)
+ .order("scene_order");
+
+ if (scenesError) {
+ throw new Error(`Video job scenes load failed: ${scenesError.message}`);
+ }
+
+ return { job: job as JobRow, scenes: (scenes ?? []) as SceneRow[] };
+}
+
+async function updateJobStatus(jobId: string, scenes: SceneRow[]) {
+ const supabase = createAdminSupabaseClient();
+ const status = statusForScenes(scenes);
+ const patch = {
+ status,
+ updated_at: nowIso(),
+ completed_at:
+ status === "done" || status === "failed" || status === "expired"
+ ? nowIso()
+ : null,
+ };
+ const { error } = await supabase
+ .from("content_studio_video_jobs")
+ .update(patch)
+ .eq("id", jobId);
+ if (error) throw new Error(`Video job update failed: ${error.message}`);
+ return status;
+}
+
+export async function getShortFormVideoJob(jobId: string): Promise {
+ const { job, scenes } = await loadJob(jobId);
+ return jobRowsToDto(job, scenes);
+}
+
+export async function createShortFormVideoJob(
+ input: CreateShortFormVideoJobInput
+): Promise {
+ const apiKey = process.env.XAI_API_KEY;
+ if (!apiKey) throw new Error("XAI_API_KEY is not configured");
+
+ const supabase = createAdminSupabaseClient();
+ const model = input.model ?? contentStudioVideoModel();
+ const aspectRatio = resolveShortFormVideoAspectRatio(input);
+ const sceneFilter = input.sceneIds ? new Set(input.sceneIds) : null;
+ const scenes = input.plan.scenes.filter(
+ (scene) => !sceneFilter || sceneFilter.has(scene.id)
+ );
+ const imageUrl = referenceImageForScene(input.packet, input.useReferenceImage);
+ const createdAt = nowIso();
+ const jobId = jobIdForPlan(input.plan.id);
+
+ const jobRow = {
+ id: jobId,
+ plan_id: input.plan.id,
+ packet_id: input.packet?.id ?? null,
+ status: "running",
+ provider: "xai",
+ model,
+ request: {
+ sceneIds: input.sceneIds ?? null,
+ useReferenceImage: input.useReferenceImage,
+ aspectRatio,
+ resolution: input.resolution,
+ pollIntervalMs: input.pollIntervalMs,
+ timeoutMs: input.timeoutMs,
+ },
+ plan: input.plan,
+ packet: input.packet ?? null,
+ error: null,
+ created_by: input.createdBy,
+ created_at: createdAt,
+ updated_at: createdAt,
+ completed_at: null,
+ };
+
+ const { error: jobError } = await supabase
+ .from("content_studio_video_jobs")
+ .insert(jobRow);
+ if (jobError) throw new Error(`Video job create failed: ${jobError.message}`);
+
+ const sceneRows: SceneRow[] = [];
+ for (const scene of scenes) {
+ const durationSeconds = Math.min(Math.max(scene.seconds, 1), 15);
+ const prompt = buildShortFormSceneVideoPrompt(input.plan, scene);
+ const startedAt = nowIso();
+ try {
+ const requestId = await startXaiVideoGeneration({
+ apiKey,
+ model,
+ prompt,
+ durationSeconds,
+ aspectRatio,
+ resolution: input.resolution,
+ imageUrl,
+ });
+ sceneRows.push({
+ id: `${jobId}_${scene.id}`,
+ job_id: jobId,
+ plan_id: input.plan.id,
+ scene_id: scene.id,
+ scene_order: scene.order,
+ status: "running",
+ provider: "xai",
+ model,
+ request_id: requestId,
+ prompt,
+ duration_seconds: durationSeconds,
+ aspect_ratio: aspectRatio,
+ resolution: input.resolution,
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: startedAt,
+ completed_at: null,
+ });
+ } catch (error) {
+ sceneRows.push({
+ id: `${jobId}_${scene.id}`,
+ job_id: jobId,
+ plan_id: input.plan.id,
+ scene_id: scene.id,
+ scene_order: scene.order,
+ status: "failed",
+ provider: "xai",
+ model,
+ request_id: null,
+ prompt,
+ duration_seconds: durationSeconds,
+ aspect_ratio: aspectRatio,
+ resolution: input.resolution,
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: error instanceof Error ? error.message : "Video generation failed",
+ started_at: startedAt,
+ completed_at: nowIso(),
+ });
+ }
+ }
+
+ const { error: scenesError } = await supabase
+ .from("content_studio_video_scenes")
+ .insert(sceneRows);
+ if (scenesError) {
+ throw new Error(`Video scene create failed: ${scenesError.message}`);
+ }
+
+ const status = await updateJobStatus(jobId, sceneRows);
+ return jobRowsToDto({ ...(jobRow as JobRow), status }, sceneRows);
+}
+
+export async function pollShortFormVideoJob(jobId: string): Promise {
+ const apiKey = process.env.XAI_API_KEY;
+ if (!apiKey) throw new Error("XAI_API_KEY is not configured");
+
+ const supabase = createAdminSupabaseClient();
+ const { job, scenes } = await loadJob(jobId);
+ const nextScenes = [...scenes];
+
+ for (const scene of nextScenes) {
+ if (scene.status !== "running" || !scene.request_id) continue;
+
+ try {
+ const result = await getXaiVideoStatusOnce({
+ apiKey,
+ requestId: scene.request_id,
+ });
+
+ if (result.status === "pending") continue;
+
+ if (result.status !== "done" || !result.video?.url) {
+ scene.status = result.status === "expired" ? "expired" : "failed";
+ scene.error =
+ result.error?.message ?? `xAI video generation ${result.status ?? "failed"}`;
+ scene.completed_at = nowIso();
+ } else {
+ const video = await fetchVideoBytes(result.video.url);
+ const stored = await uploadVideoToStorage({
+ planId: scene.plan_id,
+ sceneId: scene.scene_id,
+ requestId: scene.request_id,
+ bytes: video.bytes,
+ contentType: video.contentType,
+ });
+ scene.status = "done";
+ scene.temporary_url = result.video.url;
+ scene.permanent_url = stored.publicUrl;
+ scene.storage_path = stored.path;
+ scene.error = null;
+ scene.completed_at = nowIso();
+ }
+
+ const { error } = await supabase
+ .from("content_studio_video_scenes")
+ .update({
+ status: scene.status,
+ temporary_url: scene.temporary_url,
+ permanent_url: scene.permanent_url,
+ storage_path: scene.storage_path,
+ error: scene.error,
+ completed_at: scene.completed_at,
+ updated_at: nowIso(),
+ })
+ .eq("id", scene.id);
+ if (error) throw new Error(`Video scene update failed: ${error.message}`);
+ } catch (error) {
+ scene.status = "failed";
+ scene.error = error instanceof Error ? error.message : "Video polling failed";
+ scene.completed_at = nowIso();
+ await supabase
+ .from("content_studio_video_scenes")
+ .update({
+ status: scene.status,
+ error: scene.error,
+ completed_at: scene.completed_at,
+ updated_at: nowIso(),
+ })
+ .eq("id", scene.id);
+ }
+ }
+
+ const status = await updateJobStatus(jobId, nextScenes);
+ return jobRowsToDto({ ...job, status, updated_at: nowIso() }, nextScenes);
+}
+
+export async function retryShortFormVideoJobScene(
+ jobId: string,
+ sceneId: string
+): Promise {
+ const apiKey = process.env.XAI_API_KEY;
+ if (!apiKey) throw new Error("XAI_API_KEY is not configured");
+
+ const supabase = createAdminSupabaseClient();
+ const { job, scenes } = await loadJob(jobId);
+ const scene = scenes.find((candidate) => candidate.scene_id === sceneId);
+ if (!scene) throw new Error("Video scene not found");
+
+ const requestId = await startXaiVideoGeneration({
+ apiKey,
+ model: scene.model,
+ prompt: scene.prompt,
+ durationSeconds: scene.duration_seconds,
+ aspectRatio: scene.aspect_ratio,
+ resolution: scene.resolution,
+ });
+
+ Object.assign(scene, {
+ status: "running",
+ request_id: requestId,
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: nowIso(),
+ completed_at: null,
+ });
+
+ const { error } = await supabase
+ .from("content_studio_video_scenes")
+ .update({
+ status: scene.status,
+ request_id: scene.request_id,
+ temporary_url: scene.temporary_url,
+ permanent_url: scene.permanent_url,
+ storage_path: scene.storage_path,
+ error: scene.error,
+ started_at: scene.started_at,
+ completed_at: scene.completed_at,
+ updated_at: nowIso(),
+ })
+ .eq("id", scene.id);
+ if (error) throw new Error(`Video scene retry failed: ${error.message}`);
+
+ const status = await updateJobStatus(jobId, scenes);
+ return jobRowsToDto({ ...job, status, updated_at: nowIso() }, scenes);
+}
+```
+
+- [ ] **Step 4: Export service**
+
+In `packages/web/lib/content-studio/assets/index.ts`, add:
+
+```ts
+export {
+ createShortFormVideoJob,
+ getShortFormVideoJob,
+ pollShortFormVideoJob,
+ retryShortFormVideoJobScene,
+} from "./video-jobs";
+```
+
+- [ ] **Step 5: Run tests**
+
+Run:
+
+```bash
+bun test packages/web/lib/content-studio/__tests__/video-jobs.test.ts packages/web/lib/content-studio/__tests__/video.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+Run:
+
+```bash
+git add packages/web/lib/content-studio/assets/video-jobs.ts packages/web/lib/content-studio/assets/index.ts packages/web/lib/content-studio/__tests__/video-jobs.test.ts
+git commit -m "feat(content-studio): persist video generation jobs"
+```
+
+Expected: Commit succeeds.
+
+---
+
+### Task 4: Job API Routes
+
+**Files:**
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/route.ts`
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/route.ts`
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts`
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts`
+- Create: `packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts`
+- Modify: `docs/agent/api-v1-routes.md`
+
+- [ ] **Step 1: Write failing route tests**
+
+Create `packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts`:
+
+```ts
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = {
+ getUser: vi.fn(),
+ checkIsAdmin: vi.fn(),
+ createShortFormVideoJob: vi.fn(),
+ getShortFormVideoJob: vi.fn(),
+ pollShortFormVideoJob: vi.fn(),
+ retryShortFormVideoJobScene: vi.fn(),
+};
+
+vi.mock("@/lib/supabase/server", () => ({
+ createSupabaseServerClient: () => ({
+ auth: {
+ getUser: mocks.getUser,
+ },
+ }),
+}));
+
+vi.mock("@/lib/supabase/admin", () => ({
+ checkIsAdmin: () => mocks.checkIsAdmin(),
+}));
+
+vi.mock("@/lib/content-studio/assets/video-jobs", () => ({
+ createShortFormVideoJob: (...args: unknown[]) =>
+ mocks.createShortFormVideoJob(...args),
+ getShortFormVideoJob: (...args: unknown[]) => mocks.getShortFormVideoJob(...args),
+ pollShortFormVideoJob: (...args: unknown[]) =>
+ mocks.pollShortFormVideoJob(...args),
+ retryShortFormVideoJobScene: (...args: unknown[]) =>
+ mocks.retryShortFormVideoJobScene(...args),
+}));
+
+const job = {
+ id: "job-1",
+ planId: "plan-1",
+ packetId: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ scenes: [],
+};
+
+function request(body: unknown) {
+ return new NextRequest("http://localhost/api/v1/content/assets/video-jobs", {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("content asset video job routes", () => {
+ beforeEach(() => {
+ for (const mock of Object.values(mocks)) mock.mockReset();
+ mocks.getUser.mockResolvedValue({
+ data: { user: { id: "33333333-3333-4333-8333-333333333333" } },
+ });
+ mocks.checkIsAdmin.mockResolvedValue(true);
+ process.env.XAI_API_KEY = "xai-test";
+ });
+
+ it("rejects unauthenticated job creation", async () => {
+ mocks.getUser.mockResolvedValueOnce({ data: { user: null } });
+ const { POST } = await import("../route");
+ const response = await POST(request({}));
+ expect(response.status).toBe(401);
+ });
+
+ it("creates a video job for admins", async () => {
+ mocks.createShortFormVideoJob.mockResolvedValue(job);
+ const { POST } = await import("../route");
+ const response = await POST(
+ request({
+ plan: {
+ id: "plan-1",
+ packetId: "packet-1",
+ platform: "youtube_shorts",
+ durationSeconds: 10,
+ status: "ready",
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ hook: "Hook",
+ voiceover: { text: "Voiceover", tone: "editorial", targetReadingSeconds: 10 },
+ scenes: [
+ {
+ id: "scene_1",
+ order: 1,
+ seconds: 8,
+ visualDirection: "Visual",
+ onScreenText: "Text",
+ narration: "Narration",
+ },
+ ],
+ cta: "Save this look",
+ provenance: { sourcePacketId: "packet-1", sourceResearchRunId: null },
+ },
+ useReferenceImage: true,
+ resolution: "480p",
+ })
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ job });
+ expect(mocks.createShortFormVideoJob.mock.calls[0][0].createdBy).toBe(
+ "33333333-3333-4333-8333-333333333333"
+ );
+ });
+
+ it("loads a video job", async () => {
+ mocks.getShortFormVideoJob.mockResolvedValue(job);
+ const route = await import("../[jobId]/route");
+ const response = await route.GET(
+ new NextRequest("http://localhost/api/v1/content/assets/video-jobs/job-1"),
+ { params: Promise.resolve({ jobId: "job-1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ job });
+ });
+
+ it("polls a video job", async () => {
+ mocks.pollShortFormVideoJob.mockResolvedValue({ ...job, status: "done" });
+ const route = await import("../[jobId]/poll/route");
+ const response = await route.POST(
+ new NextRequest("http://localhost/api/v1/content/assets/video-jobs/job-1/poll", { method: "POST" }),
+ { params: Promise.resolve({ jobId: "job-1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect((await response.json()).job.status).toBe("done");
+ });
+
+ it("retries a scene", async () => {
+ mocks.retryShortFormVideoJobScene.mockResolvedValue(job);
+ const route = await import("../[jobId]/scenes/[sceneId]/retry/route");
+ const response = await route.POST(
+ new NextRequest("http://localhost/api/v1/content/assets/video-jobs/job-1/scenes/scene_1/retry", { method: "POST" }),
+ { params: Promise.resolve({ jobId: "job-1", sceneId: "scene_1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ job });
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify failure**
+
+Run:
+
+```bash
+bun test packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts
+```
+
+Expected: FAIL because routes do not exist.
+
+- [ ] **Step 3: Create shared route helper in each route**
+
+Use this helper in each route file:
+
+```ts
+import { NextResponse } from "next/server";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return { user: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
+ }
+
+ const isAdmin = await checkIsAdmin(supabase, user.id);
+ if (!isAdmin) {
+ return { user: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
+ }
+
+ return { user, response: null };
+}
+```
+
+- [ ] **Step 4: Create job POST route**
+
+Create `packages/web/app/api/v1/content/assets/video-jobs/route.ts`:
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { shortFormVideoRequestSchema } from "@/lib/content-studio/schemas";
+import { createShortFormVideoJob } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return { user: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
+ }
+
+ const isAdmin = await checkIsAdmin(supabase, user.id);
+ if (!isAdmin) {
+ return { user: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
+ }
+
+ return { user, response: null };
+}
+
+export const maxDuration = 60;
+
+export async function POST(request: NextRequest) {
+ const { user, response } = await requireAdmin();
+ if (response) return response;
+
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+
+ const body = await request.json().catch(() => null);
+ const parsed = shortFormVideoRequestSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid video job request", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const job = await createShortFormVideoJob({
+ ...parsed.data,
+ createdBy: user.id,
+ });
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Video job create failed" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+- [ ] **Step 5: Create GET/poll/retry routes**
+
+Create `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/route.ts`:
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { getShortFormVideoJob } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!(await checkIsAdmin(supabase, user.id))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+export async function GET(
+ _request: NextRequest,
+ context: { params: Promise<{ jobId: string }> }
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+ const { jobId } = await context.params;
+ try {
+ const job = await getShortFormVideoJob(jobId);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Video job load failed" },
+ { status: 404 }
+ );
+ }
+}
+```
+
+Create `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts`:
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { pollShortFormVideoJob } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!(await checkIsAdmin(supabase, user.id))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+export const maxDuration = 60;
+
+export async function POST(
+ _request: NextRequest,
+ context: { params: Promise<{ jobId: string }> }
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+ const { jobId } = await context.params;
+ try {
+ const job = await pollShortFormVideoJob(jobId);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Video job poll failed" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+Create `packages/web/app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts`:
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { retryShortFormVideoJobScene } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!(await checkIsAdmin(supabase, user.id))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+export async function POST(
+ _request: NextRequest,
+ context: { params: Promise<{ jobId: string; sceneId: string }> }
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+ const { jobId, sceneId } = await context.params;
+ try {
+ const job = await retryShortFormVideoJobScene(jobId, sceneId);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Video scene retry failed" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+- [ ] **Step 6: Update route docs**
+
+In `docs/agent/api-v1-routes.md`, add rows near content asset routes:
+
+```md
+| `/api/v1/content/assets/video-jobs` | `POST` | Create an admin-only xAI short-form video job and persist scene request IDs. |
+| `/api/v1/content/assets/video-jobs/[jobId]` | `GET` | Load a persisted short-form video job for resume. |
+| `/api/v1/content/assets/video-jobs/[jobId]/poll` | `POST` | Poll running xAI scenes once and persist completed Storage URLs or failures. |
+| `/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry` | `POST` | Restart one failed or expired xAI scene. |
+```
+
+- [ ] **Step 7: Run tests**
+
+Run:
+
+```bash
+bun test packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts packages/web/lib/content-studio/__tests__/video-jobs.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+Run:
+
+```bash
+git add packages/web/app/api/v1/content/assets/video-jobs docs/agent/api-v1-routes.md
+git commit -m "feat(content-studio): add video job API routes"
+```
+
+Expected: Commit succeeds.
+
+---
+
+### Task 5: ShortFormPanel Job UI
+
+**Files:**
+- Modify: `packages/web/app/admin/content-studio/ShortFormPanel.tsx`
+- Modify: `packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx`
+
+- [ ] **Step 1: Add failing UI test**
+
+In `packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx`, add:
+
+```ts
+it("creates a video job, polls status, and renders completed video previews", async () => {
+ const user = userEvent.setup();
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ plan: {
+ id: "short_form_packet-1",
+ packetId: "packet-1",
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ status: "ready",
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ hook: "Why this outfit works",
+ voiceover: { text: "Voiceover", tone: "editorial", targetReadingSeconds: 20 },
+ scenes: [
+ {
+ id: "scene_1",
+ order: 1,
+ seconds: 8,
+ visualDirection: "Slow push in",
+ onScreenText: "Layered denim",
+ narration: "Start with proportion",
+ },
+ ],
+ cta: "Save this look",
+ provenance: { sourcePacketId: "packet-1", sourceResearchRunId: null },
+ },
+ warning: null,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ job: {
+ id: "job-1",
+ planId: "short_form_packet-1",
+ packetId: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ scenes: [
+ {
+ id: "job-1_scene_1",
+ jobId: "job-1",
+ planId: "short_form_packet-1",
+ sceneId: "scene_1",
+ sceneOrder: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ requestId: "req-1",
+ prompt: "Prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ temporaryUrl: null,
+ permanentUrl: null,
+ storagePath: null,
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ job: {
+ id: "job-1",
+ planId: "short_form_packet-1",
+ packetId: "packet-1",
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:01.000Z",
+ completedAt: "2026-05-14T00:00:01.000Z",
+ scenes: [
+ {
+ id: "job-1_scene_1",
+ jobId: "job-1",
+ planId: "short_form_packet-1",
+ sceneId: "scene_1",
+ sceneOrder: 1,
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ requestId: "req-1",
+ prompt: "Prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ temporaryUrl: "https://vidgen.x.ai/video.mp4",
+ permanentUrl: "https://storage.example.com/video.mp4",
+ storagePath: "short_form_packet-1/videos/scene_1_req-1.mp4",
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: "2026-05-14T00:00:01.000Z",
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Generate Short Form" }));
+ await screen.findByText("Why this outfit works");
+ await user.click(screen.getByRole("button", { name: "Generate Scene Videos" }));
+ await screen.findByText("Video job: done");
+ expect(screen.getByRole("link", { name: "Download" })).toHaveAttribute(
+ "href",
+ "https://storage.example.com/video.mp4"
+ );
+});
+```
+
+- [ ] **Step 2: Run test to verify failure**
+
+Run:
+
+```bash
+bun test packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
+```
+
+Expected: FAIL because the UI still calls `/api/v1/content/assets/videos`.
+
+- [ ] **Step 3: Add job state and helper calls**
+
+In `ShortFormPanel.tsx`, add imports:
+
+```ts
+ ShortFormVideoJob,
+```
+
+Add state:
+
+```ts
+const [videoJob, setVideoJob] = useState(null);
+```
+
+Replace `generateVideos()` with:
+
+```ts
+async function generateVideos() {
+ if (!plan) return;
+ setVideoState("running");
+ setVideoError(null);
+ setVideoFailures([]);
+ try {
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ "/api/v1/content/assets/video-jobs",
+ {
+ plan,
+ packet,
+ useReferenceImage,
+ resolution,
+ }
+ );
+ setVideoJob(data.job);
+ setVideoAssets(data.job.scenes);
+ await pollVideoJob(data.job.id);
+ } catch (err) {
+ setVideoError(
+ err instanceof Error ? err.message : "Video generation failed"
+ );
+ setVideoState("error");
+ }
+}
+
+async function pollVideoJob(jobId: string) {
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ `/api/v1/content/assets/video-jobs/${jobId}/poll`,
+ {}
+ );
+ setVideoJob(data.job);
+ setVideoAssets(data.job.scenes);
+ setVideoFailures(
+ data.job.scenes
+ .filter((scene) => scene.error)
+ .map((scene) => ({ sceneId: scene.sceneId, error: scene.error ?? "" }))
+ );
+ if (data.job.status === "running") {
+ window.setTimeout(() => {
+ void pollVideoJob(jobId).catch((err) => {
+ setVideoError(err instanceof Error ? err.message : "Video polling failed");
+ setVideoState("error");
+ });
+ }, 5000);
+ return;
+ }
+ setVideoState(data.job.status === "failed" ? "error" : "idle");
+}
+
+async function retryVideoScene(sceneId: string) {
+ if (!videoJob) return;
+ setVideoState("running");
+ setVideoError(null);
+ try {
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ `/api/v1/content/assets/video-jobs/${videoJob.id}/scenes/${sceneId}/retry`,
+ {}
+ );
+ setVideoJob(data.job);
+ setVideoAssets(data.job.scenes);
+ await pollVideoJob(data.job.id);
+ } catch (err) {
+ setVideoError(err instanceof Error ? err.message : "Video retry failed");
+ setVideoState("error");
+ }
+}
+```
+
+When a new plan is generated, reset job:
+
+```ts
+setVideoJob(null);
+```
+
+Render job status before scene list:
+
+```tsx
+{videoJob && (
+
+ Video job: {videoJob.status}
+
+)}
+```
+
+Render retry button for failed/expired scenes inside the asset error branch:
+
+```tsx
+ void retryVideoScene(asset.sceneId)}
+ className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
+>
+ Retry scene
+
+```
+
+- [ ] **Step 4: Run UI tests**
+
+Run:
+
+```bash
+bun test packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+Run:
+
+```bash
+git add packages/web/app/admin/content-studio/ShortFormPanel.tsx packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
+git commit -m "feat(content-studio): add resumable video job UI"
+```
+
+Expected: Commit succeeds.
+
+---
+
+### Task 6: Verification
+
+**Files:**
+- No new files unless fixes are required.
+
+- [ ] **Step 1: Run focused tests**
+
+Run:
+
+```bash
+bun test packages/web/lib/content-studio/__tests__/assets.test.ts packages/web/lib/content-studio/__tests__/video.test.ts packages/web/lib/content-studio/__tests__/video-jobs.test.ts packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Generate API client**
+
+This is separate from Supabase table typegen. Supabase types were regenerated in Task 1 because the job service uses `createAdminSupabaseClient()` with typed table names.
+
+Run from `packages/web`:
+
+```bash
+bun run generate:api
+```
+
+Expected: Orval generation completes without errors.
+
+- [ ] **Step 3: Typecheck**
+
+Run from `packages/web`:
+
+```bash
+bun run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Targeted lint**
+
+Run from `packages/web`:
+
+```bash
+bunx eslint app/admin/content-studio/ShortFormPanel.tsx app/api/v1/content/assets/videos/route.ts app/api/v1/content/assets/video-jobs/route.ts app/api/v1/content/assets/video-jobs/[jobId]/route.ts app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts lib/content-studio/assets/video.ts lib/content-studio/assets/video-jobs.ts lib/content-studio/assets/index.ts lib/content-studio/schemas.ts lib/content-studio/__tests__/assets.test.ts lib/content-studio/__tests__/video.test.ts lib/content-studio/__tests__/video-jobs.test.ts app/api/v1/content/assets/video-jobs/__tests__/route.test.ts app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Whitespace check**
+
+Run:
+
+```bash
+git diff --check
+```
+
+Expected: No output.
+
+- [ ] **Step 6: Local smoke without spending xAI credits**
+
+Run dev server from `packages/web`:
+
+```bash
+bun run dev
+```
+
+In another shell:
+
+```bash
+bun -e 'const res = await fetch("http://localhost:3000/api/v1/content/assets/video-jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); console.log(res.status); console.log(await res.text());'
+```
+
+Expected when unauthenticated:
+
+```text
+401
+{"error":"Unauthorized"}
+```
+
+- [ ] **Step 7: Optional live smoke with explicit approval**
+
+Only after user approves external xAI spend and admin session is available:
+
+1. Open `/admin/content-studio`.
+2. Generate a short-form plan.
+3. Click `Generate Scene Videos`.
+4. Confirm a job status appears.
+5. Refresh the page, reload the job with the planned resume UI if implemented in a later extension, or continue polling if still on the page.
+6. Confirm completed scene video uses Supabase Storage public URL.
+7. Confirm failed/expired scene retry starts a new xAI request.
+
+Expected: At least one scene reaches `done` with a playable `permanentUrl`.
+
+- [ ] **Step 8: Final commit if verification fixes were required**
+
+Run:
+
+```bash
+git add
+git commit -m "fix(content-studio): stabilize video job verification"
+```
+
+Expected: Commit only if a fix was made.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Job persistence: Task 1 creates job and scene tables.
+- Supabase table typing: Task 1 regenerates `packages/web/lib/supabase/types.ts` after the local migration applies.
+- Resume polling: Task 3 adds `getShortFormVideoJob()` and `pollShortFormVideoJob()`; Task 4 exposes GET and poll routes.
+- Pending-safe polling: Task 2 adds `getXaiVideoStatusOnce()` and Task 3 uses it so a pending xAI scene stays `running` instead of being marked failed by a short timeout.
+- Per-scene retry: Task 3 adds `retryShortFormVideoJobScene()`; Task 4 exposes retry route; Task 5 adds UI retry.
+- Supabase Storage permanent URLs: Task 3 reuses `fetchVideoBytes()` and `uploadVideoToStorage()`.
+- Phase 1A compatibility: Task 2 keeps existing `/videos` service behavior unchanged.
+- Excluded scope remains excluded: no ffmpeg, no subtitles, no BGM, no voiceover, no cost tracking, no external worker scheduler.
+
+**Placeholder scan:**
+- The plan contains no TBD/TODO/fill-in steps.
+- Code snippets use concrete function names, paths, route names, SQL, and commands.
+
+**Type consistency:**
+- `ShortFormVideoJob`, `ShortFormVideoJobScene`, and `ShortFormVideoJobStatus` are defined in Task 2 and used consistently in Tasks 3-5.
+- Scene status uses `running` in schemas, SQL constraints, service rows, tests, and UI state.
+- Job route responses consistently return `{ job }`.
+- Scene route params consistently use `{ jobId, sceneId }`.
diff --git a/package.json b/package.json
index 7cf61637..b7364fe2 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@types/react-dom": "^19.2.0",
"ajv": "^8.18.0",
"gray-matter": "^4.0.3",
+ "ts-morph": "^24.0.0",
"turbo": "^2.9.3"
},
"overrides": {
diff --git a/packages/web/app/admin/content-studio/AssetPanel.tsx b/packages/web/app/admin/content-studio/AssetPanel.tsx
index 79bd43c4..12023c40 100644
--- a/packages/web/app/admin/content-studio/AssetPanel.tsx
+++ b/packages/web/app/admin/content-studio/AssetPanel.tsx
@@ -19,6 +19,7 @@ import type {
type Props = {
packet: ContentPacket | null;
variants: ContentVariant[];
+ onPlanChange?: (plan: AssetPlan | null) => void;
};
const ALL_FORMATS: AssetTargetFormat[] = [
@@ -57,7 +58,7 @@ function copyText(text: string) {
}
}
-export function AssetPanel({ packet, variants }: Props) {
+export function AssetPanel({ packet, variants, onPlanChange }: Props) {
const [selected, setSelected] = useState([
"instagram_feed",
]);
@@ -66,7 +67,7 @@ export function AssetPanel({ packet, variants }: Props) {
const [error, setError] = useState(null);
const [state, setState] = useState<"idle" | "running" | "error">("idle");
const [imageState, setImageState] = useState<"idle" | "running" | "error">(
- "idle",
+ "idle"
);
const [imageError, setImageError] = useState(null);
const [useReferenceImages, setUseReferenceImages] = useState(true);
@@ -79,7 +80,7 @@ export function AssetPanel({ packet, variants }: Props) {
setSelected((current) =>
current.includes(format)
? current.filter((item) => item !== format)
- : [...current, format],
+ : [...current, format]
);
}
@@ -98,6 +99,7 @@ export function AssetPanel({ packet, variants }: Props) {
embedHeadline,
});
setPlan(data.plan);
+ onPlanChange?.(data.plan);
setWarning(data.warning);
setState("idle");
} catch (err) {
@@ -121,11 +123,12 @@ export function AssetPanel({ packet, variants }: Props) {
useReferenceImages,
});
setPlan(data.plan);
+ onPlanChange?.(data.plan);
setImageFailures(data.failures ?? []);
setImageState("idle");
} catch (err) {
setImageError(
- err instanceof Error ? err.message : "Image generation failed",
+ err instanceof Error ? err.message : "Image generation failed"
);
setImageState("error");
}
@@ -315,7 +318,7 @@ export function AssetPanel({ packet, variants }: Props) {
copyText(
[overlay.headline, overlay.subheadline]
.filter(Boolean)
- .join("\n"),
+ .join("\n")
)
}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
diff --git a/packages/web/app/admin/content-studio/ShortFormPanel.tsx b/packages/web/app/admin/content-studio/ShortFormPanel.tsx
index 863271ad..92b7594d 100644
--- a/packages/web/app/admin/content-studio/ShortFormPanel.tsx
+++ b/packages/web/app/admin/content-studio/ShortFormPanel.tsx
@@ -1,17 +1,37 @@
"use client";
-import { useState } from "react";
-import { AlertTriangle, Copy, Loader2, Video } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import {
+ AlertTriangle,
+ Copy,
+ Download,
+ Loader2,
+ Play,
+ Video,
+} from "lucide-react";
import type {
+ AssetPlan,
ContentPacket,
ContentVariant,
+ GovernanceResult,
ShortFormPlan,
ShortFormPlatform,
+ ShortFormVideoAsset,
+ ShortFormVideoJob,
+ ShortFormVideoResolution,
} from "@/lib/content-studio";
type Props = {
packet: ContentPacket | null;
variants: ContentVariant[];
+ assetPlan?: AssetPlan | null;
+ governanceResult?: GovernanceResult | null;
+};
+
+type ShortFormVideoJobSnapshot = {
+ job: ShortFormVideoJob;
+ plan: ShortFormPlan;
+ packet: ContentPacket | null;
};
const PLATFORMS: ShortFormPlatform[] = ["instagram_reel", "youtube_shorts"];
@@ -39,19 +59,180 @@ async function postJson(url: string, body: unknown): Promise {
return data as T;
}
+async function getJson(url: string): Promise {
+ const response = await fetch(url, {
+ method: "GET",
+ credentials: "include",
+ });
+ const data = await response.json().catch(() => null);
+ if (!response.ok) {
+ const message =
+ data && typeof data.error === "string"
+ ? data.error
+ : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+ return data as T;
+}
+
+function formatJobTimestamp(value: string) {
+ return new Date(value).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
function copyText(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(text).catch(() => {});
}
}
-export function ShortFormPanel({ packet, variants }: Props) {
+export function ShortFormPanel({
+ packet,
+ variants,
+ assetPlan,
+ governanceResult,
+}: Props) {
const [platform, setPlatform] = useState("youtube_shorts");
const [duration, setDuration] = useState(20);
const [plan, setPlan] = useState(null);
const [warning, setWarning] = useState(null);
const [error, setError] = useState(null);
const [state, setState] = useState<"idle" | "running" | "error">("idle");
+ const [videoState, setVideoState] = useState<"idle" | "running" | "error">(
+ "idle"
+ );
+ const [videoError, setVideoError] = useState(null);
+ const [videoJob, setVideoJob] = useState(null);
+ const [savedVideoJobs, setSavedVideoJobs] = useState<
+ ShortFormVideoJobSnapshot[]
+ >([]);
+ const [resumeState, setResumeState] = useState<"idle" | "running" | "error">(
+ "idle"
+ );
+ const [resumeError, setResumeError] = useState(null);
+ const [videoAssets, setVideoAssets] = useState([]);
+ const [videoFailures, setVideoFailures] = useState<
+ Array<{ sceneId: string; error: string }>
+ >([]);
+ const [compositionState, setCompositionState] = useState<
+ "idle" | "running" | "error"
+ >("idle");
+ const [compositionError, setCompositionError] = useState(null);
+ const [voiceoverAudioUrl, setVoiceoverAudioUrl] = useState("");
+ const [bgmAudioUrl, setBgmAudioUrl] = useState("");
+ const [useReferenceImage, setUseReferenceImage] = useState(true);
+ const [resolution, setResolution] =
+ useState("480p");
+
+ const canComposeFinal =
+ Boolean(videoJob?.scenes.length) &&
+ videoJob?.scenes.every((scene) => scene.status === "done");
+
+ const pollVideoJob = useCallback(async (jobId: string) => {
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ `/api/v1/content/assets/video-jobs/${jobId}/poll`,
+ {}
+ );
+ setVideoJob(data.job);
+ setVideoAssets(data.job.scenes);
+ setSavedVideoJobs((current) =>
+ current.map((snapshot) =>
+ snapshot.job.id === data.job.id
+ ? { ...snapshot, job: data.job }
+ : snapshot
+ )
+ );
+ setVideoFailures(
+ data.job.scenes
+ .filter((scene) => scene.error)
+ .map((scene) => ({
+ sceneId: scene.sceneId,
+ error: scene.error ?? "",
+ }))
+ );
+ if (data.job.status === "running") {
+ window.setTimeout(() => {
+ void pollVideoJob(jobId).catch((err) => {
+ setVideoError(
+ err instanceof Error ? err.message : "Video polling failed"
+ );
+ setVideoState("error");
+ });
+ }, 5000);
+ return;
+ }
+ setVideoState(data.job.status === "failed" ? "error" : "idle");
+ }, []);
+
+ const resumeVideoJob = useCallback(
+ (snapshot: ShortFormVideoJobSnapshot) => {
+ setPlan(snapshot.plan);
+ setWarning(null);
+ setVideoJob(snapshot.job);
+ setVideoAssets(snapshot.job.scenes);
+ setVideoFailures(
+ snapshot.job.scenes
+ .filter((scene) => scene.error)
+ .map((scene) => ({
+ sceneId: scene.sceneId,
+ error: scene.error ?? "",
+ }))
+ );
+ setVideoError(null);
+ setCompositionError(null);
+ setCompositionState("idle");
+ setVideoState(snapshot.job.status === "running" ? "running" : "idle");
+ if (snapshot.job.status === "running") {
+ void pollVideoJob(snapshot.job.id).catch((err) => {
+ setVideoError(
+ err instanceof Error ? err.message : "Video polling failed"
+ );
+ setVideoState("error");
+ });
+ }
+ },
+ [pollVideoJob]
+ );
+
+ useEffect(() => {
+ if (!packet?.id) {
+ setSavedVideoJobs([]);
+ return;
+ }
+
+ let cancelled = false;
+ async function loadSavedVideoJobs() {
+ setResumeState("running");
+ setResumeError(null);
+ try {
+ const data = await getJson<{ jobs: ShortFormVideoJobSnapshot[] }>(
+ `/api/v1/content/assets/video-jobs?packetId=${encodeURIComponent(
+ packet!.id
+ )}&limit=5`
+ );
+ if (cancelled) return;
+ const jobs = Array.isArray(data.jobs) ? data.jobs : [];
+ setSavedVideoJobs(jobs);
+ if (jobs[0]) resumeVideoJob(jobs[0]);
+ setResumeState("idle");
+ } catch (err) {
+ if (cancelled) return;
+ setResumeError(
+ err instanceof Error ? err.message : "Saved video jobs load failed"
+ );
+ setResumeState("error");
+ }
+ }
+
+ void loadSavedVideoJobs();
+ return () => {
+ cancelled = true;
+ };
+ }, [packet?.id, resumeVideoJob]);
async function generate() {
if (!packet) return;
@@ -69,6 +250,11 @@ export function ShortFormPanel({ packet, variants }: Props) {
});
setPlan(data.plan);
setWarning(data.warning);
+ setVideoJob(null);
+ setVideoAssets([]);
+ setVideoFailures([]);
+ setCompositionError(null);
+ setCompositionState("idle");
setState("idle");
} catch (err) {
setError(err instanceof Error ? err.message : "Short form plan failed");
@@ -76,6 +262,98 @@ export function ShortFormPanel({ packet, variants }: Props) {
}
}
+ async function generateVideos() {
+ if (!plan) return;
+ setVideoState("running");
+ setVideoError(null);
+ setVideoFailures([]);
+ try {
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ "/api/v1/content/assets/video-jobs",
+ {
+ plan,
+ packet,
+ imageAssets: assetPlan?.imageAssets ?? [],
+ governanceResult: governanceResult ?? null,
+ useReferenceImage,
+ resolution,
+ }
+ );
+ setVideoJob(data.job);
+ setVideoAssets(data.job.scenes);
+ setSavedVideoJobs((current) =>
+ [
+ { job: data.job, plan, packet },
+ ...current.filter((snapshot) => snapshot.job.id !== data.job.id),
+ ].slice(0, 5)
+ );
+ setCompositionError(null);
+ setCompositionState("idle");
+ await pollVideoJob(data.job.id);
+ } catch (err) {
+ setVideoError(
+ err instanceof Error ? err.message : "Video generation failed"
+ );
+ setVideoState("error");
+ }
+ }
+
+ async function retryVideoScene(sceneId: string) {
+ if (!videoJob) return;
+ setVideoState("running");
+ setVideoError(null);
+ try {
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ `/api/v1/content/assets/video-jobs/${videoJob.id}/scenes/${sceneId}/retry`,
+ {}
+ );
+ setVideoJob(data.job);
+ setVideoAssets(data.job.scenes);
+ setSavedVideoJobs((current) =>
+ current.map((snapshot) =>
+ snapshot.job.id === data.job.id
+ ? { ...snapshot, job: data.job }
+ : snapshot
+ )
+ );
+ await pollVideoJob(data.job.id);
+ } catch (err) {
+ setVideoError(err instanceof Error ? err.message : "Video retry failed");
+ setVideoState("error");
+ }
+ }
+
+ async function composeFinalVideo() {
+ if (!videoJob) return;
+ setCompositionState("running");
+ setCompositionError(null);
+ try {
+ const body: { voiceoverAudioUrl?: string; bgmAudioUrl?: string } = {};
+ const voiceover = voiceoverAudioUrl.trim();
+ const bgm = bgmAudioUrl.trim();
+ if (voiceover) body.voiceoverAudioUrl = voiceover;
+ if (bgm) body.bgmAudioUrl = bgm;
+ const data = await postJson<{ job: ShortFormVideoJob }>(
+ `/api/v1/content/assets/video-jobs/${videoJob.id}/compose`,
+ body
+ );
+ setVideoJob(data.job);
+ setSavedVideoJobs((current) =>
+ current.map((snapshot) =>
+ snapshot.job.id === data.job.id
+ ? { ...snapshot, job: data.job }
+ : snapshot
+ )
+ );
+ setCompositionState("idle");
+ } catch (err) {
+ setCompositionError(
+ err instanceof Error ? err.message : "Final composition failed"
+ );
+ setCompositionState("error");
+ }
+ }
+
if (!packet) return null;
return (
@@ -143,9 +421,192 @@ export function ShortFormPanel({ packet, variants }: Props) {
{warning}
)}
+ {(resumeState === "running" ||
+ resumeError ||
+ savedVideoJobs.length > 0) && (
+
+
+
+ Saved video jobs
+
+ {resumeState === "running" && (
+
+
+ Loading
+
+ )}
+
+ {resumeError && (
+
+ {resumeError}
+
+ )}
+ {savedVideoJobs.length > 0 && (
+
+ {savedVideoJobs.map((snapshot) => (
+
+
+
+ {snapshot.job.status} ·{" "}
+ {formatJobTimestamp(snapshot.job.updatedAt)}
+
+
+ {platformLabel(snapshot.plan.platform)} ·{" "}
+ {snapshot.plan.durationSeconds}s ·{" "}
+ {snapshot.job.scenes.length} scenes
+
+
+
resumeVideoJob(snapshot)}
+ disabled={videoJob?.id === snapshot.job.id}
+ className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ {videoJob?.id === snapshot.job.id ? "Loaded" : "Resume"}
+
+
+ ))}
+
+ )}
+
+ )}
{plan && (
+
+
+ {videoState === "running" ? (
+
+ ) : (
+
+ )}
+ {videoAssets.some((asset) => asset.permanentUrl)
+ ? "Regenerate Scene Videos"
+ : "Generate Scene Videos"}
+
+
+ setResolution(event.target.value as ShortFormVideoResolution)
+ }
+ className="h-9 rounded-md border border-border bg-background px-2 text-xs text-foreground"
+ aria-label="Video resolution"
+ >
+ 480p
+ 720p
+
+
+ setUseReferenceImage(event.target.checked)}
+ className="h-3.5 w-3.5 rounded border-border"
+ />
+ Use source image
+
+
+ Calls xAI Grok Imagine Video and stores completed clips.
+
+
+
+ {videoJob && (
+
+ setVoiceoverAudioUrl(event.target.value)}
+ placeholder="Voiceover audio URL"
+ className="h-9 rounded-md border border-border bg-background px-2 text-xs text-foreground"
+ aria-label="Voiceover audio URL"
+ />
+ setBgmAudioUrl(event.target.value)}
+ placeholder="BGM audio URL"
+ className="h-9 rounded-md border border-border bg-background px-2 text-xs text-foreground"
+ aria-label="BGM audio URL"
+ />
+
+ {compositionState === "running" ? (
+
+ ) : (
+
+ )}
+ Compose Final
+
+
+ )}
+
+ {videoError && (
+
+ )}
+ {compositionError && (
+
+ )}
+ {videoFailures.length > 0 && (
+
+
Some scenes failed:
+
+ {videoFailures.map((failure) => (
+
+ {failure.sceneId}: {failure.error}
+
+ ))}
+
+
+ )}
+ {videoJob && (
+
+ Video job: {videoJob.status}
+ {videoJob.composition
+ ? ` · composition: ${videoJob.composition.status}`
+ : ""}
+
+ )}
+ {videoJob?.composition?.finalAsset && (
+
+ )}
+
@@ -206,6 +667,48 @@ export function ShortFormPanel({ packet, variants }: Props) {
Narration: {scene.narration}
+ {videoAssets
+ .filter((asset) => asset.sceneId === scene.id)
+ .map((asset) => (
+
+ {asset.permanentUrl ? (
+ <>
+
+
+
+
+ Download
+
+ >
+ ) : asset.error ? (
+
+
+ Video failed: {asset.error}
+
+
+ void retryVideoScene(asset.sceneId)
+ }
+ className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
+ >
+ Retry scene
+
+
+ ) : null}
+
+ ))}
))}
diff --git a/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx
index b11b6e7f..1e09b1c8 100644
--- a/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx
+++ b/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
-import { fireEvent, render, waitFor } from "@testing-library/react";
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
// @ts-expect-error jsdom is installed in this workspace without declarations.
import { JSDOM } from "jsdom";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
@@ -72,6 +72,7 @@ const packet: ContentPacket = {
describe("AssetPanel", () => {
afterEach(() => {
+ cleanup();
vi.restoreAllMocks();
});
@@ -101,7 +102,6 @@ describe("AssetPanel", () => {
plan: {
id: "asset-plan-1",
packetId: "packet-1",
- researchRunId: null,
status: "draft",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
@@ -126,7 +126,6 @@ describe("AssetPanel", () => {
],
provenance: {
sourcePacketId: "packet-1",
- sourceResearchRunId: null,
},
},
warning: null,
@@ -142,4 +141,59 @@ describe("AssetPanel", () => {
});
expect(view.getByLabelText("Copy prompt for instagram_feed")).toBeTruthy();
});
+
+ it("publishes generated image plans to the parent flow", async () => {
+ const onPlanChange = vi.fn();
+ vi.spyOn(global, "fetch").mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ plan: {
+ id: "asset-plan-1",
+ packetId: "packet-1",
+ status: "draft",
+ createdAt: "2026-05-07T00:00:00.000Z",
+ updatedAt: "2026-05-07T00:00:00.000Z",
+ imageAssets: [
+ {
+ id: "instagram_feed_1",
+ format: "instagram_feed",
+ prompt: "Editorial airport denim still",
+ size: "1024x1280",
+ editMode: "generate",
+ previewUrl: "https://storage.example.com/generated.png",
+ altText: "alt",
+ },
+ ],
+ overlayText: [],
+ provenance: {
+ sourcePacketId: "packet-1",
+ },
+ },
+ warning: null,
+ }),
+ } as Response);
+
+ const view = render(
+
+ );
+
+ fireEvent.click(view.getByRole("button", { name: "Generate Asset Plan" }));
+
+ await waitFor(() => {
+ expect(onPlanChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "asset-plan-1",
+ imageAssets: [
+ expect.objectContaining({
+ previewUrl: "https://storage.example.com/generated.png",
+ }),
+ ],
+ })
+ );
+ });
+ });
});
diff --git a/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
index 18b7c2a6..b375f116 100644
--- a/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
+++ b/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
-import { fireEvent, render, waitFor } from "@testing-library/react";
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
// @ts-expect-error jsdom is installed in this workspace without declarations.
import { JSDOM } from "jsdom";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
@@ -70,8 +70,16 @@ const packet: ContentPacket = {
createdAt: "2026-05-07T00:00:00.000Z",
};
+function savedVideoJobsResponse() {
+ return new Response(JSON.stringify({ jobs: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
describe("ShortFormPanel", () => {
afterEach(() => {
+ cleanup();
vi.restoreAllMocks();
});
@@ -85,7 +93,40 @@ describe("ShortFormPanel", () => {
});
it("renders platform and duration controls when a packet exists", () => {
- const view = render(
);
+ const view = render(
+
+ );
expect(view.getByLabelText("Platform")).toBeTruthy();
expect(view.getByLabelText("Duration seconds")).toBeTruthy();
@@ -95,45 +136,78 @@ describe("ShortFormPanel", () => {
});
it("submits a short-form request and renders hook, voiceover, and scenes", async () => {
- vi.spyOn(global, "fetch").mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- plan: {
- id: "short-form-1",
+ vi.spyOn(global, "fetch")
+ .mockResolvedValueOnce(savedVideoJobsResponse())
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ plan: {
+ id: "short-form-1",
+ packetId: "packet-1",
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ status: "draft",
+ createdAt: "2026-05-07T00:00:00.000Z",
+ updatedAt: "2026-05-07T00:00:00.000Z",
+ hook: "Why this airport look reads premium",
+ voiceover: {
+ text: "The silhouette does the work before the brand does.",
+ tone: "decoded_editorial",
+ targetReadingSeconds: 4,
+ },
+ scenes: [
+ {
+ id: "scene_1",
+ order: 1,
+ seconds: 4,
+ visualDirection: "Open with the full silhouette.",
+ onScreenText: "Silhouette first",
+ narration: "Open with the full silhouette.",
+ },
+ ],
+ cta: "Save for reference",
+ provenance: {
+ sourcePacketId: "packet-1",
+ },
+ },
+ warning: null,
+ }),
+ } as Response);
+
+ const view = render(
+
);
+ }}
+ />
+ );
fireEvent.click(view.getByRole("button", { name: "Generate Short Form" }));
@@ -147,4 +221,213 @@ describe("ShortFormPanel", () => {
).toBeTruthy();
expect(view.getByTestId("scene-1")).toBeTruthy();
});
+
+ it("creates a video job, polls status, and renders completed video previews", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(savedVideoJobsResponse())
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ plan: {
+ id: "short_form_packet-1",
+ packetId: "packet-1",
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ status: "ready",
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ hook: "Why this outfit works",
+ voiceover: {
+ text: "Voiceover",
+ tone: "editorial",
+ targetReadingSeconds: 20,
+ },
+ scenes: [
+ {
+ id: "scene_1",
+ order: 1,
+ seconds: 8,
+ visualDirection: "Slow push in",
+ onScreenText: "Layered denim",
+ narration: "Start with proportion",
+ },
+ ],
+ cta: "Save this look",
+ provenance: {
+ sourcePacketId: "packet-1",
+ },
+ },
+ warning: null,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ job: {
+ id: "job-1",
+ planId: "short_form_packet-1",
+ packetId: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ scenes: [
+ {
+ id: "job-1_scene_1",
+ jobId: "job-1",
+ planId: "short_form_packet-1",
+ sceneId: "scene_1",
+ sceneOrder: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ requestId: "req-1",
+ prompt: "Prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ temporaryUrl: null,
+ permanentUrl: null,
+ storagePath: null,
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ job: {
+ id: "job-1",
+ planId: "short_form_packet-1",
+ packetId: "packet-1",
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:01.000Z",
+ completedAt: "2026-05-14T00:00:01.000Z",
+ scenes: [
+ {
+ id: "job-1_scene_1",
+ jobId: "job-1",
+ planId: "short_form_packet-1",
+ sceneId: "scene_1",
+ sceneOrder: 1,
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ requestId: "req-1",
+ prompt: "Prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ temporaryUrl: "https://vidgen.x.ai/video.mp4",
+ permanentUrl: "https://storage.example.com/video.mp4",
+ storagePath: "short_form_packet-1/videos/scene_1_req-1.mp4",
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: "2026-05-14T00:00:01.000Z",
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ )
+ );
+ vi.spyOn(global, "fetch").mockImplementation(fetchMock);
+
+ const view = render(
+
+ );
+
+ fireEvent.click(view.getByRole("button", { name: "Generate Short Form" }));
+ await waitFor(() => {
+ expect(view.getByText("Why this outfit works")).toBeTruthy();
+ });
+
+ fireEvent.click(
+ view.getByRole("button", { name: "Generate Scene Videos" })
+ );
+
+ await waitFor(() => {
+ expect(view.getByText("Video job: done")).toBeTruthy();
+ });
+ expect(
+ JSON.parse(
+ fetchMock.mock.calls.find(
+ ([url, init]) =>
+ url === "/api/v1/content/assets/video-jobs" &&
+ init &&
+ "body" in init
+ )?.[1]?.body as string
+ ).imageAssets
+ ).toEqual([
+ expect.objectContaining({
+ id: "asset-1",
+ previewUrl: "https://storage.example.com/generated.png",
+ }),
+ ]);
+ expect(
+ JSON.parse(
+ fetchMock.mock.calls.find(
+ ([url, init]) =>
+ url === "/api/v1/content/assets/video-jobs" &&
+ init &&
+ "body" in init
+ )?.[1]?.body as string
+ ).governanceResult
+ ).toMatchObject({
+ verdict: "needs_review",
+ riskLevel: "medium",
+ });
+ expect(
+ view.getByRole("link", { name: /Download/ }).getAttribute("href")
+ ).toBe("https://storage.example.com/video.mp4");
+ });
});
diff --git a/packages/web/app/admin/content-studio/page.tsx b/packages/web/app/admin/content-studio/page.tsx
index 57444f1e..1e8bc650 100644
--- a/packages/web/app/admin/content-studio/page.tsx
+++ b/packages/web/app/admin/content-studio/page.tsx
@@ -16,6 +16,7 @@ import { AssetPanel } from "./AssetPanel";
import { PostPickerModal } from "./PostPickerModal";
import { ShortFormPanel } from "./ShortFormPanel";
import type {
+ AssetPlan,
ContentGenerationMode,
ContentPacket,
ContentVariant,
@@ -383,6 +384,7 @@ export default function ContentStudioPage() {
const [generationWarning, setGenerationWarning] = useState
(
null
);
+ const [assetPlan, setAssetPlan] = useState(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [state, setState] = useState("idle");
const [error, setError] = useState(null);
@@ -413,6 +415,7 @@ export default function ContentStudioPage() {
setError(null);
setGovernance(null);
setGenerationWarning(null);
+ setAssetPlan(null);
setVariants([]);
try {
@@ -489,6 +492,7 @@ export default function ContentStudioPage() {
async function handleOpenPacket(id: string) {
setState("loading");
setError(null);
+ setAssetPlan(null);
try {
const data = await getJson<{
@@ -637,8 +641,17 @@ export default function ContentStudioPage() {
variants={variants}
onStatusChange={handleVariantStatusChange}
/>
-
-
+
+
}
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+
+ const body = await request.json().catch(() => ({}));
+ const parsed = shortFormVideoCompositionRequestSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid composition request", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const { jobId } = await context.params;
+ try {
+ const job = await composeShortFormVideoJob(jobId, parsed.data);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Video composition failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts b/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts
new file mode 100644
index 00000000..aa18edc0
--- /dev/null
+++ b/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/poll/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+import { pollShortFormVideoJob } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!(await checkIsAdmin(supabase, user.id))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+export const maxDuration = 60;
+
+export async function POST(
+ _request: NextRequest,
+ context: { params: Promise<{ jobId: string }> }
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+ const { jobId } = await context.params;
+ try {
+ const job = await pollShortFormVideoJob(jobId);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error: error instanceof Error ? error.message : "Video job poll failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/route.ts b/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/route.ts
new file mode 100644
index 00000000..2810c761
--- /dev/null
+++ b/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getShortFormVideoJob } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!(await checkIsAdmin(supabase, user.id))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+export async function GET(
+ _request: NextRequest,
+ context: { params: Promise<{ jobId: string }> }
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+ const { jobId } = await context.params;
+ try {
+ const job = await getShortFormVideoJob(jobId);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error: error instanceof Error ? error.message : "Video job load failed",
+ },
+ { status: 404 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts b/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts
new file mode 100644
index 00000000..5f12f81e
--- /dev/null
+++ b/packages/web/app/api/v1/content/assets/video-jobs/[jobId]/scenes/[sceneId]/retry/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { retryShortFormVideoJobScene } from "@/lib/content-studio/assets/video-jobs";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!(await checkIsAdmin(supabase, user.id))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return null;
+}
+
+export async function POST(
+ _request: NextRequest,
+ context: { params: Promise<{ jobId: string; sceneId: string }> }
+) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+ const { jobId, sceneId } = await context.params;
+ try {
+ const job = await retryShortFormVideoJobScene(jobId, sceneId);
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Video scene retry failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts b/packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts
new file mode 100644
index 00000000..3e88314a
--- /dev/null
+++ b/packages/web/app/api/v1/content/assets/video-jobs/__tests__/route.test.ts
@@ -0,0 +1,211 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = {
+ getUser: vi.fn(),
+ checkIsAdmin: vi.fn(),
+ createShortFormVideoJob: vi.fn(),
+ getShortFormVideoJob: vi.fn(),
+ pollShortFormVideoJob: vi.fn(),
+ retryShortFormVideoJobScene: vi.fn(),
+ composeShortFormVideoJob: vi.fn(),
+};
+
+vi.mock("@/lib/supabase/server", () => ({
+ createSupabaseServerClient: () => ({
+ auth: {
+ getUser: mocks.getUser,
+ },
+ }),
+}));
+
+vi.mock("@/lib/supabase/admin", () => ({
+ checkIsAdmin: () => mocks.checkIsAdmin(),
+}));
+
+vi.mock("@/lib/content-studio/assets/video-jobs", () => ({
+ createShortFormVideoJob: (...args: unknown[]) =>
+ mocks.createShortFormVideoJob(...args),
+ getShortFormVideoJob: (...args: unknown[]) =>
+ mocks.getShortFormVideoJob(...args),
+ pollShortFormVideoJob: (...args: unknown[]) =>
+ mocks.pollShortFormVideoJob(...args),
+ retryShortFormVideoJobScene: (...args: unknown[]) =>
+ mocks.retryShortFormVideoJobScene(...args),
+}));
+
+vi.mock("@/lib/content-studio/assets/video-composition", () => ({
+ composeShortFormVideoJob: (...args: unknown[]) =>
+ mocks.composeShortFormVideoJob(...args),
+}));
+
+const job = {
+ id: "job-1",
+ planId: "plan-1",
+ packetId: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ composition: null,
+ scenes: [],
+};
+
+function request(body: unknown) {
+ return new NextRequest("http://localhost/api/v1/content/assets/video-jobs", {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("content asset video job routes", () => {
+ beforeEach(() => {
+ for (const mock of Object.values(mocks)) mock.mockReset();
+ mocks.getUser.mockResolvedValue({
+ data: { user: { id: "33333333-3333-4333-8333-333333333333" } },
+ });
+ mocks.checkIsAdmin.mockResolvedValue(true);
+ process.env.XAI_API_KEY = "xai-test";
+ });
+
+ it("rejects unauthenticated job creation", async () => {
+ mocks.getUser.mockResolvedValueOnce({ data: { user: null } });
+ const { POST } = await import("../route");
+ const response = await POST(request({}));
+ expect(response.status).toBe(401);
+ });
+
+ it("creates a video job for admins", async () => {
+ mocks.createShortFormVideoJob.mockResolvedValue(job);
+ const { POST } = await import("../route");
+ const response = await POST(
+ request({
+ plan: {
+ id: "plan-1",
+ packetId: "packet-1",
+ platform: "youtube_shorts",
+ durationSeconds: 10,
+ status: "ready",
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ hook: "Hook",
+ voiceover: {
+ text: "Voiceover",
+ tone: "editorial",
+ targetReadingSeconds: 10,
+ },
+ scenes: [
+ {
+ id: "scene_1",
+ order: 1,
+ seconds: 8,
+ visualDirection: "Visual",
+ onScreenText: "Text",
+ narration: "Narration",
+ },
+ ],
+ cta: "Save this look",
+ provenance: { sourcePacketId: "packet-1", sourceResearchRunId: null },
+ },
+ useReferenceImage: true,
+ resolution: "480p",
+ })
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ job });
+ expect(mocks.createShortFormVideoJob.mock.calls[0][0].createdBy).toBe(
+ "33333333-3333-4333-8333-333333333333"
+ );
+ });
+
+ it("loads a video job", async () => {
+ mocks.getShortFormVideoJob.mockResolvedValue(job);
+ const route = await import("../[jobId]/route");
+ const response = await route.GET(
+ new NextRequest(
+ "http://localhost/api/v1/content/assets/video-jobs/job-1"
+ ),
+ { params: Promise.resolve({ jobId: "job-1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ job });
+ });
+
+ it("polls a video job", async () => {
+ mocks.pollShortFormVideoJob.mockResolvedValue({ ...job, status: "done" });
+ const route = await import("../[jobId]/poll/route");
+ const response = await route.POST(
+ new NextRequest(
+ "http://localhost/api/v1/content/assets/video-jobs/job-1/poll",
+ { method: "POST" }
+ ),
+ { params: Promise.resolve({ jobId: "job-1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect((await response.json()).job.status).toBe("done");
+ });
+
+ it("retries a scene", async () => {
+ mocks.retryShortFormVideoJobScene.mockResolvedValue(job);
+ const route = await import("../[jobId]/scenes/[sceneId]/retry/route");
+ const response = await route.POST(
+ new NextRequest(
+ "http://localhost/api/v1/content/assets/video-jobs/job-1/scenes/scene_1/retry",
+ { method: "POST" }
+ ),
+ { params: Promise.resolve({ jobId: "job-1", sceneId: "scene_1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ job });
+ });
+
+ it("composes a final video for completed scene jobs", async () => {
+ mocks.composeShortFormVideoJob.mockResolvedValue({
+ ...job,
+ composition: {
+ status: "done",
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: "2026-05-14T00:01:00.000Z",
+ finalAsset: {
+ id: "video_final_job-1",
+ jobId: "job-1",
+ planId: "plan-1",
+ status: "done",
+ aspectRatio: "9:16",
+ resolution: "480p",
+ durationSeconds: 16,
+ permanentUrl: "https://storage.example.com/final.mp4",
+ storagePath: "plan-1/videos/final_job-1.mp4",
+ error: null,
+ createdAt: "2026-05-14T00:01:00.000Z",
+ updatedAt: "2026-05-14T00:01:00.000Z",
+ },
+ },
+ });
+ const route = await import("../[jobId]/compose/route");
+ const response = await route.POST(
+ new NextRequest(
+ "http://localhost/api/v1/content/assets/video-jobs/job-1/compose",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ bgmAudioUrl: "https://storage.example.com/bgm.mp3",
+ }),
+ headers: { "Content-Type": "application/json" },
+ }
+ ),
+ { params: Promise.resolve({ jobId: "job-1" }) }
+ );
+ expect(response.status).toBe(200);
+ expect((await response.json()).job.composition.status).toBe("done");
+ expect(mocks.composeShortFormVideoJob).toHaveBeenCalledWith("job-1", {
+ bgmAudioUrl: "https://storage.example.com/bgm.mp3",
+ });
+ });
+});
diff --git a/packages/web/app/api/v1/content/assets/video-jobs/route.ts b/packages/web/app/api/v1/content/assets/video-jobs/route.ts
new file mode 100644
index 00000000..b70b8c82
--- /dev/null
+++ b/packages/web/app/api/v1/content/assets/video-jobs/route.ts
@@ -0,0 +1,99 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ createShortFormVideoJob,
+ listShortFormVideoJobsForPacket,
+} from "@/lib/content-studio/assets/video-jobs";
+import { shortFormVideoRequestSchema } from "@/lib/content-studio/schemas";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return {
+ user: null,
+ response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
+ };
+ }
+
+ const isAdmin = await checkIsAdmin(supabase, user.id);
+ if (!isAdmin) {
+ return {
+ user: null,
+ response: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
+ };
+ }
+
+ return { user, response: null };
+}
+
+export const maxDuration = 60;
+
+export async function GET(request: NextRequest) {
+ const { response } = await requireAdmin();
+ if (response) return response;
+
+ const packetId = request.nextUrl.searchParams.get("packetId");
+ const limitParam = request.nextUrl.searchParams.get("limit");
+ const limit = limitParam ? Number(limitParam) : 5;
+
+ if (!packetId) {
+ return NextResponse.json(
+ { error: "packetId is required" },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const jobs = await listShortFormVideoJobsForPacket(packetId, limit);
+ return NextResponse.json({ jobs });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error: error instanceof Error ? error.message : "Video jobs load failed",
+ },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const { user, response } = await requireAdmin();
+ if (response) return response;
+
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+
+ const body = await request.json().catch(() => null);
+ const parsed = shortFormVideoRequestSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid video job request", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const job = await createShortFormVideoJob({
+ ...parsed.data,
+ createdBy: user.id,
+ });
+ return NextResponse.json({ job });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Video job create failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/content/assets/videos/route.ts b/packages/web/app/api/v1/content/assets/videos/route.ts
new file mode 100644
index 00000000..fbd6f42d
--- /dev/null
+++ b/packages/web/app/api/v1/content/assets/videos/route.ts
@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from "next/server";
+import { shortFormVideoRequestSchema } from "@/lib/content-studio/schemas";
+import { generateShortFormVideos } from "@/lib/content-studio/assets/video";
+import { checkIsAdmin } from "@/lib/supabase/admin";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+async function requireAdmin() {
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const isAdmin = await checkIsAdmin(supabase, user.id);
+ if (!isAdmin) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ return null;
+}
+
+export const maxDuration = 300;
+
+export async function POST(request: NextRequest) {
+ const adminError = await requireAdmin();
+ if (adminError) return adminError;
+
+ if (!process.env.XAI_API_KEY) {
+ return NextResponse.json(
+ { error: "XAI_API_KEY is not configured" },
+ { status: 503 }
+ );
+ }
+
+ const body = await request.json().catch(() => null);
+ const parsed = shortFormVideoRequestSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid video request", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const result = await generateShortFormVideos(parsed.data);
+ return NextResponse.json(result);
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Video generation failed",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/lib/api/admin/magazines.ts b/packages/web/lib/api/admin/magazines.ts
index 57adde60..cfc18647 100644
--- a/packages/web/lib/api/admin/magazines.ts
+++ b/packages/web/lib/api/admin/magazines.ts
@@ -1,6 +1,7 @@
export const MAGAZINE_STATUSES = [
"draft",
"pending",
+ "generating",
"published",
"rejected",
] as const;
diff --git a/packages/web/lib/content-studio/__tests__/assets.test.ts b/packages/web/lib/content-studio/__tests__/assets.test.ts
index 90c1a890..c292eeb8 100644
--- a/packages/web/lib/content-studio/__tests__/assets.test.ts
+++ b/packages/web/lib/content-studio/__tests__/assets.test.ts
@@ -4,6 +4,10 @@ import {
assetPlanSchema,
generateThumbnailsRequestSchema,
shortFormPlanSchema,
+ shortFormVideoJobSchema,
+ shortFormVideoJobStatusSchema,
+ shortFormVideoRequestSchema,
+ shortFormVideoStatusSchema,
} from "../schemas";
import { buildAssetPlan, buildShortFormPlan } from "../assets";
import { generateChannelThumbnails } from "../assets/service";
@@ -151,9 +155,115 @@ describe("Content Studio asset schemas", () => {
});
expect(plan.voiceover.text.length).toBeGreaterThan(0);
- expect(plan.scenes.length).toBeGreaterThan(0);
+ expect(plan.scenes).toHaveLength(5);
+ expect(plan.scenes.reduce((sum, scene) => sum + scene.seconds, 0)).toBe(20);
+ expect(plan.scenes.map((scene) => scene.id)).toEqual([
+ "scene_1",
+ "scene_2",
+ "scene_3",
+ "scene_4",
+ "scene_5",
+ ]);
+ expect(plan.scenes[1].visualDirection).toContain("styling formula");
+ expect(plan.scenes[4].visualDirection).toContain("CTA");
expect(plan.platform).toBe("youtube_shorts");
});
+
+ it("parses a short-form video request with xAI-compatible defaults", () => {
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ const parsed = shortFormVideoRequestSchema.parse({
+ plan,
+ packet,
+ useReferenceImage: true,
+ governanceResult: {
+ verdict: "needs_review",
+ riskLevel: "medium",
+ flags: ["rightsRisk"],
+ requiredActions: ["Review source rights before export"],
+ },
+ });
+
+ expect(parsed.resolution).toBe("480p");
+ expect(parsed.timeoutMs).toBe(120000);
+ expect(parsed.governanceResult?.verdict).toBe("needs_review");
+ });
+
+ it("rejects unsupported short-form video resolution values", () => {
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ const parsed = shortFormVideoRequestSchema.safeParse({
+ plan,
+ resolution: "1080p",
+ });
+
+ expect(parsed.success).toBe(false);
+ });
+
+ it("parses a persisted short-form video job with scene statuses", () => {
+ const parsed = shortFormVideoJobSchema.parse({
+ id: "video_job_short_form_packet-1_1700000000000",
+ planId: "short_form_packet-1",
+ packetId: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: {
+ useReferenceImage: true,
+ resolution: "480p",
+ },
+ error: null,
+ createdAt: "2026-05-14T00:00:00.000Z",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ scenes: [
+ {
+ id: "video_job_short_form_packet-1_1700000000000_scene_1",
+ jobId: "video_job_short_form_packet-1_1700000000000",
+ planId: "short_form_packet-1",
+ sceneId: "scene_1",
+ sceneOrder: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ requestId: "req-scene-1",
+ prompt: "Scene prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ temporaryUrl: null,
+ permanentUrl: null,
+ storagePath: null,
+ error: null,
+ startedAt: "2026-05-14T00:00:00.000Z",
+ completedAt: null,
+ },
+ ],
+ });
+
+ expect(parsed.status).toBe("running");
+ expect(parsed.scenes[0].requestId).toBe("req-scene-1");
+ });
+
+ it("rejects unknown short-form video job statuses", () => {
+ expect(
+ shortFormVideoJobStatusSchema.safeParse("queued_forever").success
+ ).toBe(false);
+ });
+
+ it("allows running scene video status for persisted jobs", () => {
+ expect(shortFormVideoStatusSchema.parse("running")).toBe("running");
+ });
});
const imagePrompts = {
diff --git a/packages/web/lib/content-studio/__tests__/video-jobs.test.ts b/packages/web/lib/content-studio/__tests__/video-jobs.test.ts
new file mode 100644
index 00000000..ec749b91
--- /dev/null
+++ b/packages/web/lib/content-studio/__tests__/video-jobs.test.ts
@@ -0,0 +1,659 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ContentPacket } from "../schemas";
+import { buildShortFormPlan } from "../assets";
+import {
+ createShortFormVideoJob,
+ listShortFormVideoJobsForPacket,
+ pollShortFormVideoJob,
+ retryShortFormVideoJobScene,
+} from "../assets/video-jobs";
+import {
+ buildFfmpegCompositionCommand,
+ composeShortFormVideoJob,
+} from "../assets/video-composition";
+
+const rows = {
+ jobs: [] as Array>,
+ scenes: [] as Array>,
+};
+
+function table(
+ name: "content_studio_video_jobs" | "content_studio_video_scenes"
+) {
+ const target = name === "content_studio_video_jobs" ? rows.jobs : rows.scenes;
+ const matches = (
+ items: Array>,
+ column: string,
+ value: unknown
+ ) => items.filter((item) => item[column] === value);
+ return {
+ insert: vi.fn(async (value: unknown) => {
+ const items = Array.isArray(value) ? value : [value];
+ target.push(...(items as Array>));
+ return { error: null };
+ }),
+ update: vi.fn((patch: Record) => ({
+ eq: vi.fn(async (column: string, value: unknown) => {
+ for (const item of target) {
+ if (item[column] === value) Object.assign(item, patch);
+ }
+ return { error: null };
+ }),
+ })),
+ select: vi.fn(() => ({
+ eq: vi.fn((column: string, value: unknown) => ({
+ order: vi.fn(() => ({
+ limit: vi.fn(async (count: number) => ({
+ data: matches(target, column, value)
+ .sort((a, b) =>
+ String(b.created_at ?? "").localeCompare(
+ String(a.created_at ?? "")
+ )
+ )
+ .slice(0, count),
+ error: null,
+ })),
+ then: (
+ resolve: (value: {
+ data: Array>;
+ error: null;
+ }) => void
+ ) =>
+ resolve({
+ data: matches(target, column, value),
+ error: null,
+ }),
+ })),
+ limit: vi.fn(async (count: number) => ({
+ data: matches(target, column, value).slice(0, count),
+ error: null,
+ })),
+ maybeSingle: vi.fn(async () => ({
+ data: target.find((item) => item[column] === value) ?? null,
+ error: null,
+ })),
+ })),
+ })),
+ };
+}
+
+vi.mock("@/lib/supabase/admin-server", () => ({
+ createAdminSupabaseClient: () => ({
+ from: (name: "content_studio_video_jobs" | "content_studio_video_scenes") =>
+ table(name),
+ storage: {
+ from: () => ({
+ upload: vi.fn(async () => ({ error: null })),
+ getPublicUrl: (path: string) => ({
+ data: { publicUrl: `https://storage.example.com/${path}` },
+ }),
+ }),
+ },
+ }),
+}));
+
+const packet: ContentPacket = {
+ id: "packet-1",
+ postId: "post-1",
+ sourceImage: "https://example.com/source.jpg",
+ title: "Airport denim layered look",
+ hook: "Why this airport look reads premium",
+ artist: null,
+ group: null,
+ context: null,
+ detectedItems: [],
+ styleSummary:
+ "Layered denim with structured outerwear keeps proportion crisp.",
+ whyItWorks: "The silhouette does the work before the brand does.",
+ alternatives: { budget: [], mid: [], premium: [] },
+ disclosureFlags: {
+ aiGenerated: false,
+ syntheticMedia: false,
+ sponsored: false,
+ rightsRisk: false,
+ },
+ riskLevel: "low",
+ reviewStatus: "draft",
+ createdAt: "2026-05-07T00:00:00.000Z",
+};
+
+function restoreEnv(name: string, value: string | undefined) {
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+}
+
+describe("short-form video jobs", () => {
+ afterEach(() => {
+ rows.jobs = [];
+ rows.scenes = [];
+ vi.restoreAllMocks();
+ });
+
+ it("creates a persisted job and stores xAI request ids per scene", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000);
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-1" }), { status: 200 })
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-2" }), { status: 200 })
+ );
+
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ try {
+ const job = await createShortFormVideoJob({
+ plan,
+ packet,
+ imageAssets: [
+ {
+ id: "asset-1",
+ format: "instagram_story",
+ prompt: "Generated still",
+ size: "1024x1792",
+ editMode: "generate",
+ previewUrl: "https://storage.example.com/generated.png",
+ altText: "Generated still",
+ },
+ ],
+ useReferenceImage: true,
+ provider: "xai",
+ resolution: "480p",
+ pollIntervalMs: 3000,
+ timeoutMs: 120000,
+ governanceResult: {
+ verdict: "needs_review",
+ riskLevel: "medium",
+ flags: ["rightsRisk"],
+ requiredActions: ["Review source rights before export"],
+ },
+ createdBy: "33333333-3333-4333-8333-333333333333",
+ });
+
+ expect(job.id).toBe("video_job_short_form_packet-1_1700000000000");
+ expect(job.status).toBe("running");
+ expect(job.scenes.map((scene) => scene.requestId)).toEqual([
+ "req-1",
+ "req-2",
+ ]);
+ expect(rows.jobs).toHaveLength(1);
+ expect(rows.scenes).toHaveLength(2);
+ expect(rows.jobs[0].request).toMatchObject({
+ governanceResult: {
+ verdict: "needs_review",
+ riskLevel: "medium",
+ },
+ sceneInputs: {
+ scene_1: {
+ mode: "image-to-video",
+ imageUrl: "https://storage.example.com/generated.png",
+ source: "generated-image",
+ assetId: "asset-1",
+ },
+ },
+ });
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("lists latest persisted jobs for a packet with their stored plans", async () => {
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+ rows.jobs.push(
+ {
+ id: "job-old",
+ plan_id: plan.id,
+ packet_id: packet.id,
+ status: "failed",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan,
+ packet,
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ },
+ {
+ id: "job-new",
+ plan_id: plan.id,
+ packet_id: packet.id,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "720p" },
+ plan,
+ packet,
+ error: null,
+ created_at: "2026-05-14T00:10:00.000Z",
+ updated_at: "2026-05-14T00:10:00.000Z",
+ completed_at: null,
+ }
+ );
+ rows.scenes.push({
+ id: "job-new_scene-1",
+ job_id: "job-new",
+ plan_id: plan.id,
+ scene_id: "scene_1",
+ scene_order: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-1",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "720p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: "2026-05-14T00:10:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:10:00.000Z",
+ updated_at: "2026-05-14T00:10:00.000Z",
+ });
+
+ const jobs = await listShortFormVideoJobsForPacket(packet.id, 1);
+
+ expect(jobs).toHaveLength(1);
+ expect(jobs[0].job.id).toBe("job-new");
+ expect(jobs[0].plan.id).toBe(plan.id);
+ expect(jobs[0].job.scenes[0].requestId).toBe("req-1");
+ });
+
+ it("polls a running scene once and stores completed video output", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-1",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ status: "done",
+ video: { url: "https://vidgen.x.ai/video.mp4", duration: 8 },
+ }),
+ { status: 200 }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { "Content-Type": "video/mp4" },
+ })
+ );
+
+ try {
+ const job = await pollShortFormVideoJob("job-1");
+ expect(job.status).toBe("done");
+ expect(job.scenes[0].status).toBe("done");
+ expect(job.scenes[0].permanentUrl).toContain(
+ "https://storage.example.com/plan-1/videos/scene-1_req-1.mp4"
+ );
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("keeps a running scene running when xAI status is still pending", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "running",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-1",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(JSON.stringify({ status: "pending" }), { status: 200 })
+ );
+
+ try {
+ const job = await pollShortFormVideoJob("job-1");
+ expect(job.status).toBe("running");
+ expect(job.scenes[0].status).toBe("running");
+ expect(job.scenes[0].error).toBeNull();
+ expect(job.scenes[0].completedAt).toBeNull();
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("retries a failed scene by starting a fresh xAI request", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "partial",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "failed",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-old",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: "previous failure",
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-new" }), { status: 200 })
+ );
+
+ try {
+ const job = await retryShortFormVideoJobScene("job-1", "scene-1");
+ expect(job.status).toBe("running");
+ expect(job.scenes[0].status).toBe("running");
+ expect(job.scenes[0].requestId).toBe("req-new");
+ expect(job.scenes[0].error).toBeNull();
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("retries a scene with the stored image-to-video input", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: "plan-1",
+ packet_id: "packet-1",
+ status: "partial",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: {
+ resolution: "480p",
+ sceneInputs: {
+ "scene-1": {
+ sceneId: "scene-1",
+ sceneOrder: 1,
+ mode: "image-to-video",
+ imageUrl: "https://storage.example.com/generated.png",
+ source: "generated-image",
+ assetId: "asset-1",
+ },
+ },
+ },
+ plan: {},
+ packet: {},
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ });
+ rows.scenes.push({
+ id: "job-1_scene-1",
+ job_id: "job-1",
+ plan_id: "plan-1",
+ scene_id: "scene-1",
+ scene_order: 1,
+ status: "failed",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-old",
+ prompt: "Prompt",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: "previous failure",
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ });
+
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-new" }), { status: 200 })
+ );
+
+ try {
+ await retryShortFormVideoJobScene("job-1", "scene-1");
+ expect(
+ JSON.parse(fetchMock.mock.calls[0][1]?.body as string)
+ ).toMatchObject({
+ image: { url: "https://storage.example.com/generated.png" },
+ });
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("builds a vertical ffmpeg composition command with subtitles and audio", () => {
+ const command = buildFfmpegCompositionCommand({
+ videoUrls: [
+ "https://storage.example.com/scene-1.mp4",
+ "https://storage.example.com/scene-2.mp4",
+ ],
+ subtitlePath: "/tmp/content-studio/subtitles.srt",
+ outputPath: "/tmp/content-studio/final.mp4",
+ voiceoverAudioUrl: "https://storage.example.com/voiceover.mp3",
+ bgmAudioUrl: "https://storage.example.com/bgm.mp3",
+ ffmpegPath: "/usr/local/bin/ffmpeg",
+ });
+
+ expect(command.binary).toBe("/usr/local/bin/ffmpeg");
+ expect(command.args).toContain("-filter_complex");
+ const filters = command.args[command.args.indexOf("-filter_complex") + 1];
+ expect(filters).toContain("scale=1080:1920");
+ expect(filters).toContain("concat=n=2:v=1:a=0");
+ expect(filters).toContain("subtitles=");
+ expect(filters).toContain("amix=inputs=2");
+ expect(command.args).toContain("+faststart");
+ expect(command.args.at(-1)).toBe("/tmp/content-studio/final.mp4");
+ });
+
+ it("composes completed scene clips and persists a final asset on the job request", async () => {
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+ rows.jobs.push({
+ id: "job-1",
+ plan_id: plan.id,
+ packet_id: "packet-1",
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request: { resolution: "480p" },
+ plan,
+ packet,
+ error: null,
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ completed_at: "2026-05-14T00:00:00.000Z",
+ });
+ rows.scenes.push(
+ {
+ id: "job-1_scene_1",
+ job_id: "job-1",
+ plan_id: plan.id,
+ scene_id: "scene_1",
+ scene_order: 1,
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-1",
+ prompt: "Prompt 1",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: "https://storage.example.com/scene-1.mp4",
+ storage_path: "plan/videos/scene-1.mp4",
+ error: null,
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: "2026-05-14T00:00:00.000Z",
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ },
+ {
+ id: "job-1_scene_2",
+ job_id: "job-1",
+ plan_id: plan.id,
+ scene_id: "scene_2",
+ scene_order: 2,
+ status: "done",
+ provider: "xai",
+ model: "grok-imagine-video",
+ request_id: "req-2",
+ prompt: "Prompt 2",
+ duration_seconds: 8,
+ aspect_ratio: "9:16",
+ resolution: "480p",
+ temporary_url: null,
+ permanent_url: "https://storage.example.com/scene-2.mp4",
+ storage_path: "plan/videos/scene-2.mp4",
+ error: null,
+ started_at: "2026-05-14T00:00:00.000Z",
+ completed_at: "2026-05-14T00:00:00.000Z",
+ created_at: "2026-05-14T00:00:00.000Z",
+ updated_at: "2026-05-14T00:00:00.000Z",
+ }
+ );
+
+ const commands: string[][] = [];
+ const job = await composeShortFormVideoJob(
+ "job-1",
+ { bgmAudioUrl: "https://storage.example.com/bgm.mp3" },
+ {
+ runFfmpeg: async (command) => {
+ commands.push(command.args);
+ },
+ readOutputFile: async () => Buffer.from([1, 2, 3]),
+ }
+ );
+
+ expect(commands).toHaveLength(1);
+ expect(job.composition?.status).toBe("done");
+ expect(job.composition?.finalAsset?.permanentUrl).toContain(
+ "https://storage.example.com/short_form_packet-1/videos/final_job-1.mp4"
+ );
+ expect(rows.jobs[0].request).toMatchObject({
+ composition: {
+ status: "done",
+ finalAsset: {
+ storagePath: "short_form_packet-1/videos/final_job-1.mp4",
+ },
+ },
+ });
+ });
+});
diff --git a/packages/web/lib/content-studio/__tests__/video.test.ts b/packages/web/lib/content-studio/__tests__/video.test.ts
new file mode 100644
index 00000000..486cf7e8
--- /dev/null
+++ b/packages/web/lib/content-studio/__tests__/video.test.ts
@@ -0,0 +1,434 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ContentPacket } from "../schemas";
+import { buildShortFormPlan } from "../assets";
+import {
+ buildShortFormSceneVideoPrompt,
+ generateShortFormVideos,
+ getShortFormVideoProvider,
+ resolveShortFormVideoMode,
+ selectShortFormSceneVideoInput,
+ startXaiVideoGeneration,
+} from "../assets/video";
+
+vi.mock("@/lib/supabase/admin-server", () => ({
+ createAdminSupabaseClient: () => ({
+ storage: {
+ from: () => ({
+ upload: vi.fn(async () => ({ error: null })),
+ getPublicUrl: (path: string) => ({
+ data: {
+ publicUrl: `https://storage.example.com/${path}`,
+ },
+ }),
+ }),
+ },
+ }),
+}));
+
+const packet: ContentPacket = {
+ id: "packet-1",
+ postId: "post-1",
+ sourceImage: "https://example.com/source.jpg",
+ title: "Airport denim layered look",
+ hook: "Why this airport look reads premium",
+ artist: null,
+ group: null,
+ context: null,
+ detectedItems: [],
+ styleSummary:
+ "Layered denim with structured outerwear keeps proportion crisp.",
+ whyItWorks: "The silhouette does the work before the brand does.",
+ alternatives: { budget: [], mid: [], premium: [] },
+ disclosureFlags: {
+ aiGenerated: false,
+ syntheticMedia: false,
+ sponsored: false,
+ rightsRisk: false,
+ },
+ riskLevel: "low",
+ reviewStatus: "draft",
+ createdAt: "2026-05-07T00:00:00.000Z",
+};
+
+function restoreEnv(name: string, value: string | undefined) {
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+}
+
+describe("short-form video generation", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("builds identity-safe decoded editorial scene prompts", () => {
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ const referencePrompt = buildShortFormSceneVideoPrompt(plan, plan.scenes[0], {
+ mode: "reference-to-video",
+ source: "packet-source",
+ });
+ expect(referencePrompt).toContain("Identity lock");
+ expect(referencePrompt).toContain("Preserve the same person's face shape");
+ expect(referencePrompt).toContain("Do not morph the face");
+ expect(referencePrompt).toContain("celebrity outfit context");
+
+ const textOnlyPrompt = buildShortFormSceneVideoPrompt(plan, plan.scenes[0]);
+ expect(textOnlyPrompt).toContain("original editorial model or silhouette");
+ expect(textOnlyPrompt).toContain("Do not recreate a real celebrity's exact face");
+ });
+
+ it("resolves provider video modes from available inputs", () => {
+ expect(resolveShortFormVideoMode({})).toBe("text-to-video");
+ expect(
+ resolveShortFormVideoMode({ imageUrl: "https://example.com/a.jpg" })
+ ).toBe("image-to-video");
+ expect(
+ resolveShortFormVideoMode({
+ referenceImageUrls: ["https://example.com/a.jpg"],
+ })
+ ).toBe("reference-to-video");
+ expect(
+ resolveShortFormVideoMode({
+ sourceVideoUrl: "https://example.com/a.mp4",
+ })
+ ).toBe("edit-video");
+ expect(
+ resolveShortFormVideoMode({
+ mode: "extend-video",
+ sourceVideoUrl: "https://example.com/a.mp4",
+ })
+ ).toBe("extend-video");
+ });
+
+ it("selects generated images before packet references for scene video input", () => {
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ expect(
+ selectShortFormSceneVideoInput({
+ packet,
+ imageAssets: [
+ {
+ id: "asset-1",
+ format: "instagram_story",
+ prompt: "Generated still",
+ size: "1024x1792",
+ editMode: "generate",
+ previewUrl: "https://storage.example.com/generated.png",
+ altText: "Generated still",
+ },
+ ],
+ useReferenceImage: true,
+ scene: plan.scenes[0],
+ sceneIndex: 0,
+ })
+ ).toMatchObject({
+ mode: "image-to-video",
+ imageUrl: "https://storage.example.com/generated.png",
+ source: "generated-image",
+ assetId: "asset-1",
+ });
+
+ expect(
+ selectShortFormSceneVideoInput({
+ packet,
+ imageAssets: [],
+ useReferenceImage: true,
+ scene: plan.scenes[0],
+ sceneIndex: 0,
+ })
+ ).toMatchObject({
+ mode: "reference-to-video",
+ referenceImageUrls: ["https://example.com/source.jpg"],
+ source: "packet-source",
+ });
+
+ expect(
+ selectShortFormSceneVideoInput({
+ packet: { ...packet, sourceImage: "/local-image.jpg" },
+ imageAssets: [],
+ useReferenceImage: true,
+ scene: plan.scenes[0],
+ sceneIndex: 0,
+ })
+ ).toMatchObject({
+ mode: "text-to-video",
+ source: "text-only",
+ });
+ });
+
+ it("maps all xAI provider modes to the correct REST endpoint shape", async () => {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(
+ async () =>
+ new Response(JSON.stringify({ request_id: "req-1" }), {
+ status: 200,
+ })
+ );
+
+ const base = {
+ apiKey: "xai-test",
+ model: "grok-imagine-video",
+ prompt: "Prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16" as const,
+ resolution: "480p" as const,
+ };
+
+ await startXaiVideoGeneration(base);
+ await startXaiVideoGeneration({
+ ...base,
+ mode: "image-to-video",
+ imageUrl: "https://example.com/source.jpg",
+ });
+ await startXaiVideoGeneration({
+ ...base,
+ mode: "reference-to-video",
+ referenceImageUrls: ["https://example.com/ref-1.jpg"],
+ });
+ await startXaiVideoGeneration({
+ ...base,
+ mode: "edit-video",
+ sourceVideoUrl: "https://example.com/source.mp4",
+ });
+ await startXaiVideoGeneration({
+ ...base,
+ mode: "extend-video",
+ sourceVideoUrl: "https://example.com/source.mp4",
+ durationSeconds: 12,
+ });
+
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ "https://api.x.ai/v1/videos/generations",
+ "https://api.x.ai/v1/videos/generations",
+ "https://api.x.ai/v1/videos/generations",
+ "https://api.x.ai/v1/videos/edits",
+ "https://api.x.ai/v1/videos/extensions",
+ ]);
+
+ expect(
+ JSON.parse(fetchMock.mock.calls[1][1]?.body as string).image
+ ).toEqual({ url: "https://example.com/source.jpg" });
+ expect(
+ JSON.parse(fetchMock.mock.calls[2][1]?.body as string).reference_images
+ ).toEqual([{ url: "https://example.com/ref-1.jpg" }]);
+ expect(JSON.parse(fetchMock.mock.calls[3][1]?.body as string)).toEqual({
+ model: "grok-imagine-video",
+ prompt: "Prompt",
+ video: { url: "https://example.com/source.mp4" },
+ });
+ expect(
+ JSON.parse(fetchMock.mock.calls[4][1]?.body as string).duration
+ ).toBe(10);
+ });
+
+ it("exposes xAI through the provider abstraction", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-provider" }), {
+ status: 200,
+ })
+ );
+
+ try {
+ const provider = getShortFormVideoProvider("xai");
+ const requestId = await provider.start({
+ model: "grok-imagine-video",
+ prompt: "Prompt",
+ durationSeconds: 8,
+ aspectRatio: "9:16",
+ resolution: "480p",
+ });
+
+ expect(provider.id).toBe("xai");
+ expect(requestId).toBe("req-provider");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("polls xAI video generation and uploads completed clips to storage", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-scene-1" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ status: "done",
+ video: {
+ url: "https://vidgen.x.ai/video.mp4",
+ duration: 4,
+ },
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { "Content-Type": "video/mp4" },
+ })
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-scene-2" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ status: "done",
+ video: {
+ url: "https://vidgen.x.ai/video-2.mp4",
+ duration: 15,
+ },
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(new Uint8Array([4, 5, 6]), {
+ status: 200,
+ headers: { "Content-Type": "video/mp4" },
+ })
+ );
+
+ const plan = buildShortFormPlan({
+ packet,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ try {
+ const result = await generateShortFormVideos({
+ plan,
+ packet,
+ imageAssets: [],
+ sceneIds: ["scene_1", "scene_2"],
+ useReferenceImage: true,
+ provider: "xai",
+ resolution: "480p",
+ pollIntervalMs: 500,
+ timeoutMs: 1000,
+ });
+
+ expect(result.failures).toEqual([]);
+ expect(result.assets).toHaveLength(2);
+ expect(result.assets[0].requestId).toBe("req-scene-1");
+ expect(result.assets[0].permanentUrl).toContain(
+ "https://storage.example.com/short_form_packet-1/videos/scene_1_req-scene-1.mp4"
+ );
+ expect(result.assets[1].durationSeconds).toBe(plan.scenes[1].seconds);
+
+ const firstStartBody = JSON.parse(
+ fetchMock.mock.calls[0][1]?.body as string
+ );
+ expect(firstStartBody.prompt).toContain("Identity lock");
+ expect(firstStartBody.prompt).toContain("Do not morph the face");
+ expect(firstStartBody.image).toBeUndefined();
+ expect(firstStartBody.reference_images).toEqual([
+ { url: "https://example.com/source.jpg" },
+ ]);
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+
+ it("omits relative source images because xAI requires public image URLs", async () => {
+ const previousKey = process.env.XAI_API_KEY;
+ process.env.XAI_API_KEY = "xai-test";
+
+ const fetchMock = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ request_id: "req-scene-1" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ status: "done",
+ video: {
+ url: "https://vidgen.x.ai/video.mp4",
+ duration: 4,
+ },
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { "Content-Type": "video/mp4" },
+ })
+ );
+
+ const localPacket = {
+ ...packet,
+ sourceImage: "/api/v1/vton/placeholder?kind=post",
+ };
+ const plan = buildShortFormPlan({
+ packet: localPacket,
+ platform: "youtube_shorts",
+ durationSeconds: 20,
+ variants: [],
+ });
+
+ try {
+ const result = await generateShortFormVideos({
+ plan,
+ packet: localPacket,
+ imageAssets: [],
+ sceneIds: ["scene_1"],
+ useReferenceImage: true,
+ provider: "xai",
+ resolution: "480p",
+ pollIntervalMs: 500,
+ timeoutMs: 1000,
+ });
+
+ expect(result.failures).toEqual([]);
+ const firstStartBody = JSON.parse(
+ fetchMock.mock.calls[0][1]?.body as string
+ );
+ expect(firstStartBody.image).toBeUndefined();
+ expect(firstStartBody.reference_images).toBeUndefined();
+ } finally {
+ restoreEnv("XAI_API_KEY", previousKey);
+ }
+ });
+});
diff --git a/packages/web/lib/content-studio/assets/index.ts b/packages/web/lib/content-studio/assets/index.ts
index cfe5e784..9c5715b3 100644
--- a/packages/web/lib/content-studio/assets/index.ts
+++ b/packages/web/lib/content-studio/assets/index.ts
@@ -1,4 +1,21 @@
export { buildAssetPlan, buildShortFormPlan } from "./plan";
+export {
+ generateShortFormVideos,
+ contentStudioVideoModel,
+ contentStudioVideoProvider,
+ getShortFormVideoProvider,
+} from "./video";
+export {
+ createShortFormVideoJob,
+ getShortFormVideoJob,
+ pollShortFormVideoJob,
+ retryShortFormVideoJobScene,
+} from "./video-jobs";
+export {
+ buildFfmpegCompositionCommand,
+ buildShortFormCompositionSubtitles,
+ composeShortFormVideoJob,
+} from "./video-composition";
export {
generateAssetPlan,
generateShortFormPlan,
diff --git a/packages/web/lib/content-studio/assets/plan.ts b/packages/web/lib/content-studio/assets/plan.ts
index 0fe7d571..b15051a3 100644
--- a/packages/web/lib/content-studio/assets/plan.ts
+++ b/packages/web/lib/content-studio/assets/plan.ts
@@ -95,6 +95,30 @@ function shortenHeadline(text: string, maxWords: number): string {
return words.slice(0, maxWords).join(" ").trim();
}
+function allocateShortFormSceneSeconds(durationSeconds: number) {
+ const weights = [0.15, 0.2, 0.3, 0.25, 0.1];
+ const seconds = weights.map((weight) =>
+ Math.max(1, Math.floor(durationSeconds * weight))
+ );
+ let remaining =
+ durationSeconds - seconds.reduce((sum, value) => sum + value, 0);
+
+ for (let index = 0; remaining > 0; index = (index + 1) % seconds.length) {
+ seconds[index] += 1;
+ remaining -= 1;
+ }
+
+ for (let index = seconds.length - 1; remaining < 0; index -= 1) {
+ if (seconds[index] > 1) {
+ seconds[index] -= 1;
+ remaining += 1;
+ }
+ if (index === 0) index = seconds.length;
+ }
+
+ return seconds;
+}
+
function imagePrompt(
packet: ContentPacket,
format: AssetTargetFormat,
@@ -166,7 +190,8 @@ export function buildAssetPlan(input: BuildAssetPlanInput) {
export function buildShortFormPlan(input: BuildShortFormPlanInput) {
const now = new Date().toISOString();
- const detailSeconds = Math.max(2, input.durationSeconds - 4);
+ const [hookSeconds, contextSeconds, decodeSeconds, stylingSeconds, ctaSeconds] =
+ allocateShortFormSceneSeconds(input.durationSeconds);
const plan = {
id: `short_form_${input.packet.id}`,
packetId: input.packet.id,
@@ -185,21 +210,50 @@ export function buildShortFormPlan(input: BuildShortFormPlanInput) {
{
id: "scene_1",
order: 1,
- seconds: 4,
- visualDirection: "Open with the full silhouette and first impression.",
+ seconds: hookSeconds,
+ visualDirection:
+ "Hook: open with a mysterious hero-item close-up, then reveal the full outfit silhouette in a clean 9:16 magazine frame. Leave Korean title space at the top.",
onScreenText: input.packet.hook,
narration: input.packet.hook,
},
{
id: "scene_2",
order: 2,
- seconds: Math.min(detailSeconds, 30),
- visualDirection: "Show the key details and supporting items.",
+ seconds: contextSeconds,
+ visualDirection:
+ "Context: show the source outfit mood as an editorial reference, focusing on styling formula, occasion, and silhouette rather than celebrity identity.",
+ onScreenText: "Focus on the silhouette, not the endorsement.",
+ narration: input.packet.styleSummary,
+ },
+ {
+ id: "scene_3",
+ order: 3,
+ seconds: decodeSeconds,
+ visualDirection:
+ "Decode: macro product-detail pass over texture, shape, fit, material, color, and the key item point. Keep logos clean and never invent labels.",
onScreenText: input.packet.styleSummary,
narration: input.packet.styleSummary,
},
+ {
+ id: "scene_4",
+ order: 4,
+ seconds: stylingSeconds,
+ visualDirection:
+ "Styling: show three wearable formulas for similar dressing: casual daily, minimal city, and statement fashion. Use original combinations, not an exact outfit copy.",
+ onScreenText: "Three ways to style the formula.",
+ narration: input.packet.whyItWorks,
+ },
+ {
+ id: "scene_5",
+ order: 5,
+ seconds: ctaSeconds,
+ visualDirection:
+ "CTA: close on the hero item centered in a minimal decoded.editorial layout with generous bottom caption space for save guidance.",
+ onScreenText: "Save for styling reference.",
+ narration: "Save this decode before your next shopping check.",
+ },
],
- cta: "Save for reference",
+ cta: "Save for styling reference",
provenance: {
sourcePacketId: input.packet.id,
},
diff --git a/packages/web/lib/content-studio/assets/video-composition.ts b/packages/web/lib/content-studio/assets/video-composition.ts
new file mode 100644
index 00000000..d32d8fbf
--- /dev/null
+++ b/packages/web/lib/content-studio/assets/video-composition.ts
@@ -0,0 +1,419 @@
+import { execFile } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { createAdminSupabaseClient } from "@/lib/supabase/admin-server";
+import type {
+ ShortFormPlan,
+ ShortFormVideoComposition,
+ ShortFormVideoCompositionRequest,
+ ShortFormVideoJob,
+ ShortFormVideoResolution,
+} from "../schemas";
+import {
+ shortFormPlanSchema,
+ shortFormVideoCompositionSchema,
+} from "../schemas";
+import { getShortFormVideoJob } from "./video-jobs";
+
+const CONTENT_STUDIO_BUCKET = "content-studio-assets";
+const execFileAsync = promisify(execFile);
+
+type JobRow = {
+ id: string;
+ plan_id: string;
+ packet_id: string | null;
+ status: string;
+ provider: "xai";
+ model: string;
+ request: Record;
+ plan: unknown;
+ packet: unknown | null;
+ error: string | null;
+ created_at: string;
+ updated_at: string;
+ completed_at: string | null;
+};
+
+type SceneRow = {
+ id: string;
+ job_id: string;
+ plan_id: string;
+ scene_id: string;
+ scene_order: number;
+ status: string;
+ provider: "xai";
+ model: string;
+ request_id: string | null;
+ prompt: string;
+ duration_seconds: number;
+ aspect_ratio: string;
+ resolution: ShortFormVideoResolution;
+ temporary_url: string | null;
+ permanent_url: string | null;
+ storage_path: string | null;
+ error: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+};
+
+export type FfmpegCompositionCommand = {
+ binary: string;
+ args: string[];
+};
+
+export type BuildFfmpegCompositionCommandInput = {
+ videoUrls: string[];
+ outputPath: string;
+ subtitlePath?: string;
+ voiceoverAudioUrl?: string;
+ bgmAudioUrl?: string;
+ ffmpegPath?: string;
+ width?: number;
+ height?: number;
+};
+
+export type ComposeShortFormVideoJobOptions = {
+ runFfmpeg?: (command: FfmpegCompositionCommand) => Promise;
+ readOutputFile?: (outputPath: string) => Promise;
+ tempDir?: string;
+};
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function ffmpegPath() {
+ return process.env.CONTENT_STUDIO_FFMPEG_PATH || "ffmpeg";
+}
+
+function escapeSubtitleFilterPath(value: string) {
+ return value.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'");
+}
+
+function srtTime(seconds: number) {
+ const totalMs = Math.max(0, Math.round(seconds * 1000));
+ const ms = totalMs % 1000;
+ const totalSeconds = Math.floor(totalMs / 1000);
+ const s = totalSeconds % 60;
+ const totalMinutes = Math.floor(totalSeconds / 60);
+ const m = totalMinutes % 60;
+ const h = Math.floor(totalMinutes / 60);
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(
+ s
+ ).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
+}
+
+function cleanSubtitleText(value: string) {
+ return value
+ .replace(/\r/g, "")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+export function buildShortFormCompositionSubtitles(input: {
+ plan: ShortFormPlan;
+ scenes: SceneRow[];
+}) {
+ let cursor = 0;
+ return input.scenes
+ .sort((a, b) => a.scene_order - b.scene_order)
+ .map((scene, index) => {
+ const planScene = input.plan.scenes.find(
+ (candidate) => candidate.id === scene.scene_id
+ );
+ const duration = Math.max(1, scene.duration_seconds);
+ const start = cursor;
+ const end = cursor + duration;
+ cursor = end;
+ const lines = [
+ planScene?.onScreenText || input.plan.hook,
+ planScene?.narration || input.plan.voiceover.text,
+ ].filter(Boolean);
+ return [
+ String(index + 1),
+ `${srtTime(start)} --> ${srtTime(end)}`,
+ cleanSubtitleText(lines.join("\n")),
+ ].join("\n");
+ })
+ .join("\n\n");
+}
+
+export function buildFfmpegCompositionCommand(
+ input: BuildFfmpegCompositionCommandInput
+): FfmpegCompositionCommand {
+ if (input.videoUrls.length === 0) {
+ throw new Error("At least one scene video is required for composition");
+ }
+
+ const width = input.width ?? 1080;
+ const height = input.height ?? 1920;
+ const args = ["-y"];
+ for (const url of input.videoUrls) args.push("-i", url);
+ const voiceoverIndex = input.voiceoverAudioUrl
+ ? input.videoUrls.length
+ : null;
+ if (input.voiceoverAudioUrl) args.push("-i", input.voiceoverAudioUrl);
+ const bgmIndex =
+ input.bgmAudioUrl && voiceoverIndex === null
+ ? input.videoUrls.length
+ : input.bgmAudioUrl
+ ? input.videoUrls.length + 1
+ : null;
+ if (input.bgmAudioUrl) args.push("-i", input.bgmAudioUrl);
+
+ const filters: string[] = [];
+ for (let index = 0; index < input.videoUrls.length; index += 1) {
+ filters.push(
+ `[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1,fps=30,format=yuv420p[v${index}]`
+ );
+ }
+
+ let videoLabel =
+ input.videoUrls.length === 1
+ ? "[v0]"
+ : `${input.videoUrls.map((_, index) => `[v${index}]`).join("")}concat=n=${input.videoUrls.length}:v=1:a=0[vcat]`;
+ if (input.videoUrls.length > 1) {
+ filters.push(videoLabel);
+ videoLabel = "[vcat]";
+ }
+
+ if (input.subtitlePath) {
+ filters.push(
+ `${videoLabel}subtitles='${escapeSubtitleFilterPath(
+ input.subtitlePath
+ )}':force_style='FontName=Arial,FontSize=42,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=2,Shadow=1,Alignment=2,MarginV=140'[vout]`
+ );
+ } else {
+ filters.push(`${videoLabel}null[vout]`);
+ }
+
+ if (voiceoverIndex !== null && bgmIndex !== null) {
+ filters.push(
+ `[${voiceoverIndex}:a]volume=1.0[a_voice];[${bgmIndex}:a]volume=0.25[a_bgm];[a_voice][a_bgm]amix=inputs=2:duration=longest:dropout_transition=2[aout]`
+ );
+ }
+
+ args.push("-filter_complex", filters.join(";"), "-map", "[vout]");
+ if (voiceoverIndex !== null && bgmIndex !== null) {
+ args.push("-map", "[aout]", "-c:a", "aac", "-b:a", "160k", "-shortest");
+ } else if (voiceoverIndex !== null || bgmIndex !== null) {
+ args.push(
+ "-map",
+ `${voiceoverIndex ?? bgmIndex}:a:0?`,
+ "-c:a",
+ "aac",
+ "-b:a",
+ "160k",
+ "-shortest"
+ );
+ } else {
+ args.push("-an");
+ }
+
+ args.push(
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "21",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ input.outputPath
+ );
+
+ return {
+ binary: input.ffmpegPath ?? ffmpegPath(),
+ args,
+ };
+}
+
+async function runFfmpeg(command: FfmpegCompositionCommand) {
+ await execFileAsync(command.binary, command.args, {
+ timeout: 10 * 60 * 1000,
+ maxBuffer: 1024 * 1024 * 4,
+ });
+}
+
+async function loadJobRows(
+ jobId: string
+): Promise<{ job: JobRow; scenes: SceneRow[] }> {
+ const supabase = createAdminSupabaseClient();
+ const { data: job, error: jobError } = await supabase
+ .from("content_studio_video_jobs")
+ .select("*")
+ .eq("id", jobId)
+ .maybeSingle();
+ if (jobError) throw new Error(`Video job load failed: ${jobError.message}`);
+ if (!job) throw new Error("Video job not found");
+
+ const { data: scenes, error: scenesError } = await supabase
+ .from("content_studio_video_scenes")
+ .select("*")
+ .eq("job_id", jobId)
+ .order("scene_order");
+ if (scenesError) {
+ throw new Error(`Video job scenes load failed: ${scenesError.message}`);
+ }
+
+ return { job: job as JobRow, scenes: (scenes ?? []) as SceneRow[] };
+}
+
+async function updateComposition(input: {
+ job: JobRow;
+ composition: ShortFormVideoComposition;
+ error: string | null;
+}) {
+ const supabase = createAdminSupabaseClient();
+ const request = {
+ ...input.job.request,
+ composition: input.composition,
+ };
+ const { error } = await supabase
+ .from("content_studio_video_jobs")
+ .update({
+ request,
+ error: input.error,
+ updated_at: nowIso(),
+ })
+ .eq("id", input.job.id);
+ if (error) {
+ throw new Error(`Video composition update failed: ${error.message}`);
+ }
+ input.job.request = request;
+ input.job.error = input.error;
+}
+
+async function uploadComposedVideo(input: {
+ jobId: string;
+ planId: string;
+ bytes: Buffer;
+}): Promise<{ publicUrl: string; path: string }> {
+ const supabase = createAdminSupabaseClient();
+ const storagePath = `${input.planId}/videos/final_${input.jobId}.mp4`;
+ const { error } = await supabase.storage
+ .from(CONTENT_STUDIO_BUCKET)
+ .upload(storagePath, input.bytes, {
+ contentType: "video/mp4",
+ upsert: true,
+ });
+ if (error) throw new Error(`Storage upload failed: ${error.message}`);
+
+ const { data } = supabase.storage
+ .from(CONTENT_STUDIO_BUCKET)
+ .getPublicUrl(storagePath);
+ return { publicUrl: data.publicUrl, path: storagePath };
+}
+
+export async function composeShortFormVideoJob(
+ jobId: string,
+ request: ShortFormVideoCompositionRequest = {},
+ options: ComposeShortFormVideoJobOptions = {}
+): Promise {
+ const { job, scenes } = await loadJobRows(jobId);
+ const completedScenes = scenes
+ .filter((scene) => scene.status === "done")
+ .sort((a, b) => a.scene_order - b.scene_order);
+
+ if (
+ completedScenes.length === 0 ||
+ completedScenes.length !== scenes.length ||
+ completedScenes.some((scene) => !scene.permanent_url)
+ ) {
+ throw new Error("All scene clips must be done before final composition");
+ }
+
+ const plan = shortFormPlanSchema.parse(job.plan);
+ const startedAt = nowIso();
+ await updateComposition({
+ job,
+ composition: shortFormVideoCompositionSchema.parse({
+ status: "running",
+ finalAsset: null,
+ error: null,
+ startedAt,
+ completedAt: null,
+ }),
+ error: null,
+ });
+
+ const tempDir =
+ options.tempDir ??
+ (await mkdtemp(path.join(tmpdir(), "content-studio-compose-")));
+ const subtitlePath = path.join(tempDir, "subtitles.srt");
+ const outputPath = path.join(tempDir, "final.mp4");
+
+ try {
+ await writeFile(
+ subtitlePath,
+ buildShortFormCompositionSubtitles({ plan, scenes: completedScenes }),
+ "utf8"
+ );
+ const command = buildFfmpegCompositionCommand({
+ videoUrls: completedScenes.map((scene) => scene.permanent_url as string),
+ subtitlePath,
+ outputPath,
+ voiceoverAudioUrl: request.voiceoverAudioUrl,
+ bgmAudioUrl: request.bgmAudioUrl,
+ });
+ await (options.runFfmpeg ?? runFfmpeg)(command);
+ const bytes = await (options.readOutputFile ?? readFile)(outputPath);
+ const stored = await uploadComposedVideo({
+ jobId: job.id,
+ planId: job.plan_id,
+ bytes,
+ });
+ const completedAt = nowIso();
+ await updateComposition({
+ job,
+ composition: shortFormVideoCompositionSchema.parse({
+ status: "done",
+ finalAsset: {
+ id: `video_final_${job.id}`,
+ jobId: job.id,
+ planId: job.plan_id,
+ status: "done",
+ aspectRatio: "9:16",
+ resolution: completedScenes[0].resolution,
+ durationSeconds: completedScenes.reduce(
+ (total, scene) => total + scene.duration_seconds,
+ 0
+ ),
+ permanentUrl: stored.publicUrl,
+ storagePath: stored.path,
+ error: null,
+ createdAt: completedAt,
+ updatedAt: completedAt,
+ },
+ error: null,
+ startedAt,
+ completedAt,
+ }),
+ error: null,
+ });
+ return getShortFormVideoJob(job.id);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Video composition failed";
+ await updateComposition({
+ job,
+ composition: shortFormVideoCompositionSchema.parse({
+ status: "failed",
+ finalAsset: null,
+ error: message,
+ startedAt,
+ completedAt: nowIso(),
+ }),
+ error: `Video composition failed: ${message}`,
+ });
+ throw error;
+ } finally {
+ if (!options.tempDir) {
+ await rm(tempDir, { recursive: true, force: true });
+ }
+ }
+}
diff --git a/packages/web/lib/content-studio/assets/video-jobs.ts b/packages/web/lib/content-studio/assets/video-jobs.ts
new file mode 100644
index 00000000..70589d38
--- /dev/null
+++ b/packages/web/lib/content-studio/assets/video-jobs.ts
@@ -0,0 +1,580 @@
+import { createAdminSupabaseClient } from "@/lib/supabase/admin-server";
+import type {
+ ContentPacket,
+ ShortFormPlan,
+ ShortFormVideoComposition,
+ ShortFormVideoAspectRatio,
+ ShortFormVideoJob,
+ ShortFormVideoJobScene,
+ ShortFormVideoJobStatus,
+ ShortFormVideoRequest,
+ ShortFormVideoResolution,
+ ShortFormVideoStatus,
+} from "../schemas";
+import type { SceneVideoInput } from "./video";
+import {
+ contentPacketSchema,
+ shortFormVideoCompositionSchema,
+ shortFormVideoJobSchema,
+ shortFormPlanSchema,
+} from "../schemas";
+import {
+ buildShortFormSceneVideoPrompt,
+ fetchVideoBytes,
+ getShortFormVideoProvider,
+ resolveShortFormVideoMode,
+ resolveShortFormVideoAspectRatio,
+ selectShortFormSceneVideoInput,
+ uploadVideoToStorage,
+} from "./video";
+
+type JobRow = {
+ id: string;
+ plan_id: string;
+ packet_id: string | null;
+ status: ShortFormVideoJobStatus;
+ provider: "xai";
+ model: string;
+ request: Record;
+ plan: unknown;
+ packet: unknown | null;
+ error: string | null;
+ created_at: string;
+ updated_at: string;
+ completed_at: string | null;
+};
+
+type SceneRow = {
+ id: string;
+ job_id: string;
+ plan_id: string;
+ scene_id: string;
+ scene_order: number;
+ status: ShortFormVideoStatus;
+ provider: "xai";
+ model: string;
+ request_id: string | null;
+ prompt: string;
+ duration_seconds: number;
+ aspect_ratio: ShortFormVideoAspectRatio;
+ resolution: ShortFormVideoResolution;
+ temporary_url: string | null;
+ permanent_url: string | null;
+ storage_path: string | null;
+ error: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+};
+
+export type CreateShortFormVideoJobInput = ShortFormVideoRequest & {
+ createdBy: string;
+};
+
+export type ShortFormVideoJobSnapshot = {
+ job: ShortFormVideoJob;
+ plan: ShortFormPlan;
+ packet: ContentPacket | null;
+};
+
+type SceneInputMap = Record<
+ string,
+ SceneVideoInput & { sceneOrder: number; sceneId: string }
+>;
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function jobIdForPlan(planId: string) {
+ return `video_job_${planId}_${Date.now()}`;
+}
+
+function sceneRowToDto(row: SceneRow): ShortFormVideoJobScene {
+ return {
+ id: row.id,
+ jobId: row.job_id,
+ planId: row.plan_id,
+ sceneId: row.scene_id,
+ sceneOrder: row.scene_order,
+ status: row.status,
+ provider: row.provider,
+ model: row.model,
+ requestId: row.request_id,
+ prompt: row.prompt,
+ durationSeconds: row.duration_seconds,
+ aspectRatio: row.aspect_ratio,
+ resolution: row.resolution,
+ temporaryUrl: row.temporary_url,
+ permanentUrl: row.permanent_url,
+ storagePath: row.storage_path,
+ error: row.error,
+ startedAt: row.started_at,
+ completedAt: row.completed_at,
+ };
+}
+
+function compositionFromRequest(
+ request: Record
+): ShortFormVideoComposition | null {
+ const parsed = shortFormVideoCompositionSchema.safeParse(request.composition);
+ return parsed.success ? parsed.data : null;
+}
+
+function jobRowsToDto(job: JobRow, scenes: SceneRow[]): ShortFormVideoJob {
+ return shortFormVideoJobSchema.parse({
+ id: job.id,
+ planId: job.plan_id,
+ packetId: job.packet_id,
+ status: job.status,
+ provider: job.provider,
+ model: job.model,
+ request: job.request,
+ error: job.error,
+ createdAt: job.created_at,
+ updatedAt: job.updated_at,
+ completedAt: job.completed_at,
+ composition: compositionFromRequest(job.request),
+ scenes: scenes
+ .sort((a, b) => a.scene_order - b.scene_order)
+ .map(sceneRowToDto),
+ });
+}
+
+function jobRowsToSnapshot(
+ job: JobRow,
+ scenes: SceneRow[]
+): ShortFormVideoJobSnapshot {
+ const parsedPacket = contentPacketSchema.safeParse(job.packet);
+ return {
+ job: jobRowsToDto(job, scenes),
+ plan: shortFormPlanSchema.parse(job.plan),
+ packet: parsedPacket.success ? parsedPacket.data : null,
+ };
+}
+
+function statusForScenes(scenes: SceneRow[]): ShortFormVideoJobStatus {
+ if (scenes.length === 0) return "failed";
+ if (scenes.every((scene) => scene.status === "done")) return "done";
+ if (scenes.every((scene) => scene.status === "expired")) return "expired";
+ if (scenes.every((scene) => scene.status === "failed")) return "failed";
+ if (
+ scenes.some(
+ (scene) => scene.status === "running" || scene.status === "pending"
+ )
+ ) {
+ return "running";
+ }
+ return "partial";
+}
+
+function sceneInputsForRequest(input: {
+ request: ShortFormVideoRequest;
+ scenes: ShortFormPlan["scenes"];
+}): SceneInputMap {
+ return Object.fromEntries(
+ input.scenes.map((scene, sceneIndex) => {
+ const sceneInput = selectShortFormSceneVideoInput({
+ packet: input.request.packet,
+ imageAssets: input.request.imageAssets,
+ useReferenceImage: input.request.useReferenceImage,
+ scene,
+ sceneIndex,
+ });
+ return [
+ scene.id,
+ {
+ ...sceneInput,
+ sceneId: scene.id,
+ sceneOrder: scene.order,
+ },
+ ];
+ })
+ );
+}
+
+function sceneInputFromJobRequest(
+ request: Record,
+ sceneId: string
+): SceneVideoInput {
+ const sceneInputs =
+ request.sceneInputs &&
+ typeof request.sceneInputs === "object" &&
+ !Array.isArray(request.sceneInputs)
+ ? (request.sceneInputs as Record)
+ : {};
+ const raw = sceneInputs[sceneId];
+
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return { mode: "text-to-video", source: "text-only" };
+ }
+
+ const candidate = raw as Record;
+ const mode =
+ candidate.mode === "text-to-video" ||
+ candidate.mode === "image-to-video" ||
+ candidate.mode === "reference-to-video" ||
+ candidate.mode === "edit-video" ||
+ candidate.mode === "extend-video"
+ ? candidate.mode
+ : resolveShortFormVideoMode({});
+ const imageUrl =
+ typeof candidate.imageUrl === "string" ? candidate.imageUrl : undefined;
+ const sourceVideoUrl =
+ typeof candidate.sourceVideoUrl === "string"
+ ? candidate.sourceVideoUrl
+ : undefined;
+ const referenceImageUrls = Array.isArray(candidate.referenceImageUrls)
+ ? candidate.referenceImageUrls.filter(
+ (item): item is string => typeof item === "string"
+ )
+ : undefined;
+ const source =
+ candidate.source === "generated-image" ||
+ candidate.source === "packet-source" ||
+ candidate.source === "text-only"
+ ? candidate.source
+ : "text-only";
+
+ return {
+ mode,
+ imageUrl,
+ referenceImageUrls,
+ sourceVideoUrl,
+ source,
+ assetId:
+ typeof candidate.assetId === "string" ? candidate.assetId : undefined,
+ };
+}
+
+async function loadJob(
+ jobId: string
+): Promise<{ job: JobRow; scenes: SceneRow[] }> {
+ const supabase = createAdminSupabaseClient();
+ const { data: job, error: jobError } = await supabase
+ .from("content_studio_video_jobs")
+ .select("*")
+ .eq("id", jobId)
+ .maybeSingle();
+
+ if (jobError) throw new Error(`Video job load failed: ${jobError.message}`);
+ if (!job) throw new Error("Video job not found");
+
+ const { data: scenes, error: scenesError } = await supabase
+ .from("content_studio_video_scenes")
+ .select("*")
+ .eq("job_id", jobId)
+ .order("scene_order");
+
+ if (scenesError) {
+ throw new Error(`Video job scenes load failed: ${scenesError.message}`);
+ }
+
+ return { job: job as JobRow, scenes: (scenes ?? []) as SceneRow[] };
+}
+
+async function updateJobStatus(jobId: string, scenes: SceneRow[]) {
+ const supabase = createAdminSupabaseClient();
+ const status = statusForScenes(scenes);
+ const terminal =
+ status === "done" || status === "failed" || status === "expired";
+ const patch = {
+ status,
+ updated_at: nowIso(),
+ completed_at: terminal ? nowIso() : null,
+ };
+ const { error } = await supabase
+ .from("content_studio_video_jobs")
+ .update(patch)
+ .eq("id", jobId);
+ if (error) throw new Error(`Video job update failed: ${error.message}`);
+ return status;
+}
+
+export async function getShortFormVideoJob(
+ jobId: string
+): Promise {
+ const { job, scenes } = await loadJob(jobId);
+ return jobRowsToDto(job, scenes);
+}
+
+export async function listShortFormVideoJobsForPacket(
+ packetId: string,
+ limit = 5
+): Promise {
+ const supabase = createAdminSupabaseClient();
+ const requestedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 5;
+ const safeLimit = Math.min(Math.max(requestedLimit, 1), 20);
+ const { data: jobs, error } = await supabase
+ .from("content_studio_video_jobs")
+ .select("*")
+ .eq("packet_id", packetId)
+ .order("created_at", { ascending: false })
+ .limit(safeLimit);
+
+ if (error) throw new Error(`Video jobs list failed: ${error.message}`);
+
+ const snapshots: ShortFormVideoJobSnapshot[] = [];
+ for (const job of (jobs ?? []) as JobRow[]) {
+ const { scenes } = await loadJob(job.id);
+ snapshots.push(jobRowsToSnapshot(job, scenes));
+ }
+ return snapshots;
+}
+
+export async function createShortFormVideoJob(
+ input: CreateShortFormVideoJobInput
+): Promise {
+ const provider = getShortFormVideoProvider(input.provider);
+ provider.requireApiKey();
+
+ const supabase = createAdminSupabaseClient();
+ const model = input.model ?? provider.defaultModel();
+ const aspectRatio = resolveShortFormVideoAspectRatio(input);
+ const sceneFilter = input.sceneIds ? new Set(input.sceneIds) : null;
+ const scenes = input.plan.scenes.filter(
+ (scene) => !sceneFilter || sceneFilter.has(scene.id)
+ );
+ const sceneInputs = sceneInputsForRequest({ request: input, scenes });
+ const createdAt = nowIso();
+ const jobId = jobIdForPlan(input.plan.id);
+
+ const jobRow = {
+ id: jobId,
+ plan_id: input.plan.id,
+ packet_id: input.packet?.id ?? null,
+ status: "running" as const,
+ provider: provider.id,
+ model,
+ request: {
+ sceneIds: input.sceneIds ?? null,
+ useReferenceImage: input.useReferenceImage,
+ governanceResult: input.governanceResult ?? null,
+ aspectRatio,
+ resolution: input.resolution,
+ pollIntervalMs: input.pollIntervalMs,
+ timeoutMs: input.timeoutMs,
+ sceneInputs,
+ },
+ plan: input.plan,
+ packet: input.packet ?? null,
+ error: null,
+ created_by: input.createdBy,
+ created_at: createdAt,
+ updated_at: createdAt,
+ completed_at: null,
+ };
+
+ const { error: jobError } = await supabase
+ .from("content_studio_video_jobs")
+ .insert(jobRow);
+ if (jobError) throw new Error(`Video job create failed: ${jobError.message}`);
+
+ const sceneRows: SceneRow[] = [];
+ for (const scene of scenes) {
+ const durationSeconds = Math.min(Math.max(scene.seconds, 1), 15);
+ const sceneInput = sceneInputs[scene.id] ?? {
+ mode: "text-to-video" as const,
+ source: "text-only" as const,
+ };
+ const prompt = buildShortFormSceneVideoPrompt(input.plan, scene, sceneInput);
+ const startedAt = nowIso();
+ try {
+ const requestId = await provider.start({
+ model,
+ prompt,
+ durationSeconds,
+ aspectRatio,
+ resolution: input.resolution,
+ mode: sceneInput.mode,
+ imageUrl: sceneInput.imageUrl,
+ referenceImageUrls: sceneInput.referenceImageUrls,
+ sourceVideoUrl: sceneInput.sourceVideoUrl,
+ });
+ sceneRows.push({
+ id: `${jobId}_${scene.id}`,
+ job_id: jobId,
+ plan_id: input.plan.id,
+ scene_id: scene.id,
+ scene_order: scene.order,
+ status: "running",
+ provider: provider.id,
+ model,
+ request_id: requestId,
+ prompt,
+ duration_seconds: durationSeconds,
+ aspect_ratio: aspectRatio,
+ resolution: input.resolution,
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: startedAt,
+ completed_at: null,
+ });
+ } catch (error) {
+ sceneRows.push({
+ id: `${jobId}_${scene.id}`,
+ job_id: jobId,
+ plan_id: input.plan.id,
+ scene_id: scene.id,
+ scene_order: scene.order,
+ status: "failed",
+ provider: provider.id,
+ model,
+ request_id: null,
+ prompt,
+ duration_seconds: durationSeconds,
+ aspect_ratio: aspectRatio,
+ resolution: input.resolution,
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error:
+ error instanceof Error ? error.message : "Video generation failed",
+ started_at: startedAt,
+ completed_at: nowIso(),
+ });
+ }
+ }
+
+ const { error: scenesError } = await supabase
+ .from("content_studio_video_scenes")
+ .insert(sceneRows);
+ if (scenesError) {
+ throw new Error(`Video scene create failed: ${scenesError.message}`);
+ }
+
+ const status = await updateJobStatus(jobId, sceneRows);
+ return jobRowsToDto({ ...(jobRow as JobRow), status }, sceneRows);
+}
+
+export async function pollShortFormVideoJob(
+ jobId: string
+): Promise {
+ const supabase = createAdminSupabaseClient();
+ const { job, scenes } = await loadJob(jobId);
+ const provider = getShortFormVideoProvider(job.provider);
+ provider.requireApiKey();
+ const nextScenes = [...scenes];
+
+ for (const scene of nextScenes) {
+ if (scene.status !== "running" || !scene.request_id) continue;
+
+ try {
+ const result = await provider.getStatusOnce(scene.request_id);
+
+ if (result.status === "pending") continue;
+
+ if (result.status !== "done" || !result.video?.url) {
+ scene.status = result.status === "expired" ? "expired" : "failed";
+ scene.error =
+ result.error?.message ??
+ `xAI video generation ${result.status ?? "failed"}`;
+ scene.completed_at = nowIso();
+ } else {
+ const video = await fetchVideoBytes(result.video.url);
+ const stored = await uploadVideoToStorage({
+ planId: scene.plan_id,
+ sceneId: scene.scene_id,
+ requestId: scene.request_id,
+ bytes: video.bytes,
+ contentType: video.contentType,
+ });
+ scene.status = "done";
+ scene.temporary_url = result.video.url;
+ scene.permanent_url = stored.publicUrl;
+ scene.storage_path = stored.path;
+ scene.error = null;
+ scene.completed_at = nowIso();
+ }
+
+ const { error } = await supabase
+ .from("content_studio_video_scenes")
+ .update({
+ status: scene.status,
+ temporary_url: scene.temporary_url,
+ permanent_url: scene.permanent_url,
+ storage_path: scene.storage_path,
+ error: scene.error,
+ completed_at: scene.completed_at,
+ updated_at: nowIso(),
+ })
+ .eq("id", scene.id);
+ if (error) throw new Error(`Video scene update failed: ${error.message}`);
+ } catch (error) {
+ scene.status = "failed";
+ scene.error =
+ error instanceof Error ? error.message : "Video polling failed";
+ scene.completed_at = nowIso();
+ await supabase
+ .from("content_studio_video_scenes")
+ .update({
+ status: scene.status,
+ error: scene.error,
+ completed_at: scene.completed_at,
+ updated_at: nowIso(),
+ })
+ .eq("id", scene.id);
+ }
+ }
+
+ const updatedAt = nowIso();
+ const status = await updateJobStatus(jobId, nextScenes);
+ return jobRowsToDto({ ...job, status, updated_at: updatedAt }, nextScenes);
+}
+
+export async function retryShortFormVideoJobScene(
+ jobId: string,
+ sceneId: string
+): Promise {
+ const supabase = createAdminSupabaseClient();
+ const { job, scenes } = await loadJob(jobId);
+ const provider = getShortFormVideoProvider(job.provider);
+ provider.requireApiKey();
+ const scene = scenes.find((candidate) => candidate.scene_id === sceneId);
+ if (!scene) throw new Error("Video scene not found");
+ const sceneInput = sceneInputFromJobRequest(job.request, scene.scene_id);
+
+ const requestId = await provider.start({
+ model: scene.model,
+ prompt: scene.prompt,
+ durationSeconds: scene.duration_seconds,
+ aspectRatio: scene.aspect_ratio,
+ resolution: scene.resolution,
+ mode: sceneInput.mode,
+ imageUrl: sceneInput.imageUrl,
+ referenceImageUrls: sceneInput.referenceImageUrls,
+ sourceVideoUrl: sceneInput.sourceVideoUrl,
+ });
+
+ Object.assign(scene, {
+ status: "running" as const,
+ request_id: requestId,
+ temporary_url: null,
+ permanent_url: null,
+ storage_path: null,
+ error: null,
+ started_at: nowIso(),
+ completed_at: null,
+ });
+
+ const { error } = await supabase
+ .from("content_studio_video_scenes")
+ .update({
+ status: scene.status,
+ request_id: scene.request_id,
+ temporary_url: scene.temporary_url,
+ permanent_url: scene.permanent_url,
+ storage_path: scene.storage_path,
+ error: scene.error,
+ started_at: scene.started_at,
+ completed_at: scene.completed_at,
+ updated_at: nowIso(),
+ })
+ .eq("id", scene.id);
+ if (error) throw new Error(`Video scene retry failed: ${error.message}`);
+
+ const updatedAt = nowIso();
+ const status = await updateJobStatus(jobId, scenes);
+ return jobRowsToDto({ ...job, status, updated_at: updatedAt }, scenes);
+}
diff --git a/packages/web/lib/content-studio/assets/video-prompt.ts b/packages/web/lib/content-studio/assets/video-prompt.ts
new file mode 100644
index 00000000..b09887d0
--- /dev/null
+++ b/packages/web/lib/content-studio/assets/video-prompt.ts
@@ -0,0 +1,45 @@
+import type {
+ ShortFormPlan,
+ ShortFormVideoProviderMode,
+} from "../schemas";
+
+export type ShortFormScenePromptInput = {
+ source?: "generated-image" | "packet-source" | "text-only";
+ mode?: ShortFormVideoProviderMode;
+};
+
+const EDITORIAL_SHORTS_GUARDRAILS =
+ "Create a vertical 9:16 decoded.editorial fashion short. Structure the idea as celebrity outfit context, item point, wearable styling formula, and save CTA. Use a modern Korean fashion magazine mood, premium but accessible, clean typography-safe negative space at top and bottom, soft cinematic motion, and social-media pacing. Do not imply paid endorsement unless verified. Do not invent item facts. If the exact item is unverified, frame it as inspired-by styling, not worn-by proof. Avoid distorted logos, fake brand labels, paparazzi recreation, and exact celebrity face replication.";
+
+const REFERENCE_IDENTITY_LOCK =
+ "Identity lock: use the input/reference image as the source of truth. Preserve the same person's face shape, facial features, skin tone, hairline, hairstyle, age impression, body proportions, pose logic, outfit silhouette, and hero item geometry. Animate only camera movement, subtle fabric motion, lighting, and background atmosphere. Do not morph the face, replace the person, beautify, age-shift, race-shift, gender-shift, change body shape, add extra fingers, alter hands, change facial expression drastically, swap clothing, add accessories, or introduce additional people.";
+
+const TEXT_ONLY_IDENTITY_GUARD =
+ "Use an original editorial model or silhouette only. Do not recreate a real celebrity's exact face, body, identity, paparazzi photo, or exact outfit. Focus on the styling formula and item details.";
+
+export function buildShortFormSceneVideoPrompt(
+ plan: ShortFormPlan,
+ scene: ShortFormPlan["scenes"][0],
+ sceneInput?: ShortFormScenePromptInput
+) {
+ const identityGuard =
+ sceneInput?.source === "generated-image" ||
+ sceneInput?.source === "packet-source"
+ ? REFERENCE_IDENTITY_LOCK
+ : TEXT_ONLY_IDENTITY_GUARD;
+
+ return [
+ EDITORIAL_SHORTS_GUARDRAILS,
+ identityGuard,
+ plan.hook,
+ `Scene ${scene.order}: ${scene.visualDirection}`,
+ `On-screen text direction: ${scene.onScreenText}`,
+ `Narration mood: ${scene.narration}`,
+ `Provider mode: ${sceneInput?.mode ?? "text-to-video"}.`,
+ "Editorial fashion short-form clip, clean camera movement, realistic lighting, stable anatomy, stable wardrobe, no unwanted text overlays unless explicitly requested.",
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
diff --git a/packages/web/lib/content-studio/assets/video.ts b/packages/web/lib/content-studio/assets/video.ts
new file mode 100644
index 00000000..b0623830
--- /dev/null
+++ b/packages/web/lib/content-studio/assets/video.ts
@@ -0,0 +1,536 @@
+import { createAdminSupabaseClient } from "@/lib/supabase/admin-server";
+import type {
+ AssetImage,
+ ContentPacket,
+ ShortFormPlan,
+ ShortFormVideoAspectRatio,
+ ShortFormVideoAsset,
+ ShortFormVideoProvider,
+ ShortFormVideoProviderMode,
+ ShortFormVideoRequest,
+ ShortFormVideoResolution,
+} from "../schemas";
+import { shortFormVideoAssetSchema } from "../schemas";
+import { buildShortFormSceneVideoPrompt } from "./video-prompt";
+export { buildShortFormSceneVideoPrompt } from "./video-prompt";
+
+const CONTENT_STUDIO_BUCKET = "content-studio-assets";
+const XAI_VIDEOS_URL = "https://api.x.ai/v1/videos";
+
+type XaiStartResponse = {
+ request_id?: string;
+};
+
+export type XaiVideoStatusResponse = {
+ status?: "pending" | "done" | "expired" | "failed";
+ video?: {
+ url?: string;
+ duration?: number;
+ };
+ error?: {
+ message?: string;
+ };
+};
+
+export type GenerateShortFormVideosResult = {
+ assets: ShortFormVideoAsset[];
+ failures: Array<{ sceneId: string; error: string }>;
+};
+
+export type StartProviderVideoGenerationInput = {
+ model: string;
+ prompt: string;
+ durationSeconds: number;
+ aspectRatio: ShortFormVideoAspectRatio;
+ resolution: ShortFormVideoResolution;
+ mode?: ShortFormVideoProviderMode;
+ imageUrl?: string;
+ referenceImageUrls?: string[];
+ sourceVideoUrl?: string;
+};
+
+export type SceneVideoInput = Pick<
+ StartProviderVideoGenerationInput,
+ "mode" | "imageUrl" | "referenceImageUrls" | "sourceVideoUrl"
+> & {
+ source: "generated-image" | "packet-source" | "text-only";
+ assetId?: string;
+};
+
+export type VideoProvider = {
+ id: ShortFormVideoProvider;
+ defaultModel(): string;
+ requireApiKey(): string;
+ start(input: StartProviderVideoGenerationInput): Promise;
+ getStatusOnce(requestId: string): Promise;
+};
+
+export function contentStudioVideoModel(): string {
+ return process.env.CONTENT_STUDIO_VIDEO_MODEL || "grok-imagine-video";
+}
+
+export function contentStudioVideoProvider(): ShortFormVideoProvider {
+ return "xai";
+}
+
+export function resolveShortFormVideoAspectRatio(
+ input: ShortFormVideoRequest
+): ShortFormVideoAspectRatio {
+ if (input.aspectRatio) return input.aspectRatio;
+ return input.plan.platform === "instagram_reel" ? "9:16" : "9:16";
+}
+
+export function resolveShortFormVideoMode(
+ input: Pick<
+ StartProviderVideoGenerationInput,
+ "mode" | "imageUrl" | "referenceImageUrls" | "sourceVideoUrl"
+ >
+): ShortFormVideoProviderMode {
+ if (input.mode) return input.mode;
+ if (input.sourceVideoUrl) return "edit-video";
+ if (input.referenceImageUrls?.length) return "reference-to-video";
+ if (input.imageUrl) return "image-to-video";
+ return "text-to-video";
+}
+
+function publicMediaUrl(value: string | null | undefined): string | undefined {
+ if (!value) return undefined;
+ const mediaUrl = value.trim();
+ if (!mediaUrl) return undefined;
+ if (mediaUrl.startsWith("data:image/")) return mediaUrl;
+
+ try {
+ const url = new URL(mediaUrl);
+ if (url.protocol === "http:" || url.protocol === "https:") {
+ return mediaUrl;
+ }
+ } catch {
+ return undefined;
+ }
+
+ return undefined;
+}
+
+export function selectShortFormSceneVideoInput(input: {
+ packet?: ContentPacket;
+ imageAssets?: AssetImage[];
+ useReferenceImage: boolean;
+ scene: ShortFormPlan["scenes"][0];
+ sceneIndex: number;
+}): SceneVideoInput {
+ const generatedImages = (input.imageAssets ?? [])
+ .map((asset) => ({
+ asset,
+ url: publicMediaUrl(asset.previewUrl),
+ }))
+ .filter((candidate): candidate is { asset: AssetImage; url: string } =>
+ Boolean(candidate.url)
+ );
+
+ if (generatedImages.length > 0) {
+ const selected =
+ generatedImages[input.sceneIndex % generatedImages.length] ??
+ generatedImages[0];
+ return {
+ mode: "image-to-video",
+ imageUrl: selected.url,
+ source: "generated-image",
+ assetId: selected.asset.id,
+ };
+ }
+
+ const sourceImage = input.useReferenceImage
+ ? publicMediaUrl(input.packet?.sourceImage)
+ : undefined;
+ if (sourceImage) {
+ return {
+ mode: "reference-to-video",
+ referenceImageUrls: [sourceImage],
+ source: "packet-source",
+ };
+ }
+
+ return {
+ mode: "text-to-video",
+ source: "text-only",
+ };
+}
+
+function requireXaiApiKey() {
+ const apiKey = process.env.XAI_API_KEY;
+ if (!apiKey) throw new Error("XAI_API_KEY is not configured");
+ return apiKey;
+}
+
+function buildXaiVideoStartRequest(input: StartProviderVideoGenerationInput): {
+ url: string;
+ body: Record;
+} {
+ const mode = resolveShortFormVideoMode(input);
+ const base = {
+ model: input.model,
+ prompt: input.prompt,
+ };
+
+ if (mode === "edit-video") {
+ if (!input.sourceVideoUrl) {
+ throw new Error("edit-video mode requires sourceVideoUrl");
+ }
+ return {
+ url: `${XAI_VIDEOS_URL}/edits`,
+ body: {
+ ...base,
+ video: { url: input.sourceVideoUrl },
+ },
+ };
+ }
+
+ if (mode === "extend-video") {
+ if (!input.sourceVideoUrl) {
+ throw new Error("extend-video mode requires sourceVideoUrl");
+ }
+ return {
+ url: `${XAI_VIDEOS_URL}/extensions`,
+ body: {
+ ...base,
+ video: { url: input.sourceVideoUrl },
+ duration: Math.min(Math.max(input.durationSeconds, 2), 10),
+ },
+ };
+ }
+
+ const generationBody: Record = {
+ ...base,
+ duration: input.durationSeconds,
+ aspect_ratio: input.aspectRatio,
+ resolution: input.resolution,
+ };
+
+ if (mode === "image-to-video") {
+ if (!input.imageUrl) {
+ throw new Error("image-to-video mode requires imageUrl");
+ }
+ generationBody.image = { url: input.imageUrl };
+ }
+
+ if (mode === "reference-to-video") {
+ if (!input.referenceImageUrls?.length) {
+ throw new Error("reference-to-video mode requires referenceImageUrls");
+ }
+ generationBody.reference_images = input.referenceImageUrls.map((url) => ({
+ url,
+ }));
+ }
+
+ return {
+ url: `${XAI_VIDEOS_URL}/generations`,
+ body: generationBody,
+ };
+}
+
+export async function startXaiVideoGeneration(input: {
+ apiKey: string;
+ model: string;
+ prompt: string;
+ durationSeconds: number;
+ aspectRatio: ShortFormVideoAspectRatio;
+ resolution: ShortFormVideoResolution;
+ mode?: ShortFormVideoProviderMode;
+ imageUrl?: string;
+ referenceImageUrls?: string[];
+ sourceVideoUrl?: string;
+}): Promise {
+ const request = buildXaiVideoStartRequest(input);
+ const response = await fetch(request.url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${input.apiKey}`,
+ },
+ body: JSON.stringify(request.body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ `xAI video request failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}`
+ );
+ }
+
+ const data = (await response.json()) as XaiStartResponse;
+ if (!data.request_id) {
+ throw new Error("xAI video response missing request_id");
+ }
+ return data.request_id;
+}
+
+export function getShortFormVideoProvider(
+ provider: ShortFormVideoProvider = contentStudioVideoProvider()
+): VideoProvider {
+ if (provider !== "xai") {
+ throw new Error(`Unsupported video provider: ${provider}`);
+ }
+
+ return {
+ id: "xai",
+ defaultModel: contentStudioVideoModel,
+ requireApiKey: requireXaiApiKey,
+ start(input) {
+ return startXaiVideoGeneration({
+ ...input,
+ apiKey: requireXaiApiKey(),
+ });
+ },
+ getStatusOnce(requestId) {
+ return getXaiVideoStatusOnce({
+ apiKey: requireXaiApiKey(),
+ requestId,
+ });
+ },
+ };
+}
+
+export async function pollXaiVideo(input: {
+ apiKey: string;
+ requestId: string;
+ pollIntervalMs: number;
+ timeoutMs: number;
+}): Promise {
+ const deadline = Date.now() + input.timeoutMs;
+
+ while (Date.now() <= deadline) {
+ const response = await fetch(`${XAI_VIDEOS_URL}/${input.requestId}`, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${input.apiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ `xAI video polling failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}`
+ );
+ }
+
+ const data = (await response.json()) as XaiVideoStatusResponse;
+ if (
+ data.status === "done" ||
+ data.status === "expired" ||
+ data.status === "failed"
+ ) {
+ return data;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, input.pollIntervalMs));
+ }
+
+ throw new Error("xAI video polling timed out");
+}
+
+export async function pollShortFormVideoProvider(input: {
+ provider: VideoProvider;
+ requestId: string;
+ pollIntervalMs: number;
+ timeoutMs: number;
+}): Promise {
+ const deadline = Date.now() + input.timeoutMs;
+
+ while (Date.now() <= deadline) {
+ const data = await input.provider.getStatusOnce(input.requestId);
+ if (
+ data.status === "done" ||
+ data.status === "expired" ||
+ data.status === "failed"
+ ) {
+ return data;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, input.pollIntervalMs));
+ }
+
+ throw new Error("Video polling timed out");
+}
+
+export async function getXaiVideoStatusOnce(input: {
+ apiKey: string;
+ requestId: string;
+}): Promise {
+ const response = await fetch(`${XAI_VIDEOS_URL}/${input.requestId}`, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${input.apiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ `xAI video polling failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}`
+ );
+ }
+
+ return (await response.json()) as XaiVideoStatusResponse;
+}
+
+export async function fetchVideoBytes(
+ url: string
+): Promise<{ bytes: Buffer; contentType: string }> {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch xAI video URL: ${response.status}`);
+ }
+ const arrayBuffer = await response.arrayBuffer();
+ return {
+ bytes: Buffer.from(arrayBuffer),
+ contentType: response.headers.get("content-type") ?? "video/mp4",
+ };
+}
+
+export async function uploadVideoToStorage(input: {
+ planId: string;
+ sceneId: string;
+ requestId: string;
+ bytes: Buffer;
+ contentType: string;
+}): Promise<{ publicUrl: string; path: string }> {
+ const supabase = createAdminSupabaseClient();
+ const ext = input.contentType.includes("webm") ? "webm" : "mp4";
+ const path = `${input.planId}/videos/${input.sceneId}_${input.requestId}.${ext}`;
+
+ const { error } = await supabase.storage
+ .from(CONTENT_STUDIO_BUCKET)
+ .upload(path, input.bytes, {
+ contentType: input.contentType,
+ upsert: false,
+ });
+
+ if (error) {
+ throw new Error(`Storage upload failed: ${error.message}`);
+ }
+
+ const { data } = supabase.storage
+ .from(CONTENT_STUDIO_BUCKET)
+ .getPublicUrl(path);
+ return { publicUrl: data.publicUrl, path };
+}
+
+export function referenceImageForScene(
+ packet: ContentPacket | undefined,
+ useReferenceImage: boolean
+): string | undefined {
+ if (!useReferenceImage || !packet) return undefined;
+ return publicMediaUrl(packet.sourceImage);
+}
+
+export async function generateShortFormVideos(
+ input: ShortFormVideoRequest
+): Promise {
+ const provider = getShortFormVideoProvider(input.provider);
+ provider.requireApiKey();
+ const model = input.model ?? provider.defaultModel();
+ const aspectRatio = resolveShortFormVideoAspectRatio(input);
+ const sceneFilter = input.sceneIds ? new Set(input.sceneIds) : null;
+ const scenes = input.plan.scenes.filter(
+ (scene) => !sceneFilter || sceneFilter.has(scene.id)
+ );
+ const failures: GenerateShortFormVideosResult["failures"] = [];
+ const assets: ShortFormVideoAsset[] = [];
+
+ for (const [sceneIndex, scene] of scenes.entries()) {
+ const durationSeconds = Math.min(Math.max(scene.seconds, 1), 15);
+ const sceneInput = selectShortFormSceneVideoInput({
+ packet: input.packet,
+ imageAssets: input.imageAssets,
+ useReferenceImage: input.useReferenceImage,
+ scene,
+ sceneIndex,
+ });
+ const prompt = buildShortFormSceneVideoPrompt(input.plan, scene, sceneInput);
+ let requestId: string | null = null;
+
+ try {
+ requestId = await provider.start({
+ model,
+ prompt,
+ durationSeconds,
+ aspectRatio,
+ resolution: input.resolution,
+ mode: sceneInput.mode,
+ imageUrl: sceneInput.imageUrl,
+ referenceImageUrls: sceneInput.referenceImageUrls,
+ sourceVideoUrl: sceneInput.sourceVideoUrl,
+ });
+
+ const result = await pollShortFormVideoProvider({
+ provider,
+ requestId,
+ pollIntervalMs: input.pollIntervalMs,
+ timeoutMs: input.timeoutMs,
+ });
+
+ if (result.status !== "done" || !result.video?.url) {
+ throw new Error(
+ result.error?.message ??
+ `xAI video generation ${result.status ?? "failed"}`
+ );
+ }
+
+ const video = await fetchVideoBytes(result.video.url);
+ const stored = await uploadVideoToStorage({
+ planId: input.plan.id,
+ sceneId: scene.id,
+ requestId,
+ bytes: video.bytes,
+ contentType: video.contentType,
+ });
+
+ assets.push(
+ shortFormVideoAssetSchema.parse({
+ id: `video_${scene.id}`,
+ planId: input.plan.id,
+ sceneId: scene.id,
+ sceneOrder: scene.order,
+ status: "done",
+ provider: provider.id,
+ model,
+ requestId,
+ prompt,
+ durationSeconds,
+ aspectRatio,
+ resolution: input.resolution,
+ temporaryUrl: result.video.url,
+ permanentUrl: stored.publicUrl,
+ storagePath: stored.path,
+ error: null,
+ })
+ );
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Video generation failed";
+ failures.push({ sceneId: scene.id, error: message });
+ assets.push(
+ shortFormVideoAssetSchema.parse({
+ id: `video_${scene.id}`,
+ planId: input.plan.id,
+ sceneId: scene.id,
+ sceneOrder: scene.order,
+ status: "failed",
+ provider: provider.id,
+ model,
+ requestId,
+ prompt,
+ durationSeconds,
+ aspectRatio,
+ resolution: input.resolution,
+ temporaryUrl: null,
+ permanentUrl: null,
+ storagePath: null,
+ error: message,
+ })
+ );
+ }
+ }
+
+ return { assets, failures };
+}
diff --git a/packages/web/lib/content-studio/schemas.ts b/packages/web/lib/content-studio/schemas.ts
index 6f15c726..8ee24c28 100644
--- a/packages/web/lib/content-studio/schemas.ts
+++ b/packages/web/lib/content-studio/schemas.ts
@@ -121,6 +121,55 @@ export const shortFormPlatformSchema = z.enum([
"instagram_reel",
"youtube_shorts",
]);
+export const shortFormVideoStatusSchema = z.enum([
+ "pending",
+ "running",
+ "done",
+ "expired",
+ "failed",
+]);
+export const shortFormVideoJobStatusSchema = z.enum([
+ "pending",
+ "running",
+ "partial",
+ "done",
+ "failed",
+ "expired",
+]);
+export const shortFormVideoCompositionStatusSchema = z.enum([
+ "pending",
+ "running",
+ "done",
+ "failed",
+]);
+export const shortFormVideoProviderSchema = z.enum(["xai"]);
+export const shortFormVideoProviderModeSchema = z.enum([
+ "text-to-video",
+ "image-to-video",
+ "reference-to-video",
+ "edit-video",
+ "extend-video",
+]);
+export const shortFormVideoResolutionSchema = z.enum(["480p", "720p"]);
+export const shortFormVideoAspectRatioSchema = z.enum([
+ "1:1",
+ "16:9",
+ "9:16",
+ "4:3",
+ "3:4",
+ "3:2",
+ "2:3",
+]);
+
+export const assetImageSchema = z.object({
+ id: z.string(),
+ format: assetTargetFormatSchema,
+ prompt: z.string(),
+ size: z.string(),
+ editMode: assetEditModeSchema,
+ previewUrl: z.string().url().nullable(),
+ altText: z.string(),
+});
export const assetPlanSchema = z.object({
id: z.string(),
@@ -128,17 +177,7 @@ export const assetPlanSchema = z.object({
status: assetPlanStatusSchema,
createdAt: z.string(),
updatedAt: z.string(),
- imageAssets: z.array(
- z.object({
- id: z.string(),
- format: assetTargetFormatSchema,
- prompt: z.string(),
- size: z.string(),
- editMode: assetEditModeSchema,
- previewUrl: z.string().url().nullable(),
- altText: z.string(),
- })
- ),
+ imageAssets: z.array(assetImageSchema),
overlayText: z.array(
z.object({
id: z.string(),
@@ -182,6 +221,73 @@ export const shortFormPlanSchema = z.object({
}),
});
+export const shortFormVideoAssetSchema = z.object({
+ id: z.string(),
+ planId: z.string(),
+ sceneId: z.string(),
+ sceneOrder: z.number().int().min(1),
+ status: shortFormVideoStatusSchema,
+ provider: shortFormVideoProviderSchema,
+ model: z.string(),
+ requestId: z.string().nullable(),
+ prompt: z.string(),
+ durationSeconds: z.number().int().min(1).max(15),
+ aspectRatio: shortFormVideoAspectRatioSchema,
+ resolution: shortFormVideoResolutionSchema,
+ temporaryUrl: z.string().url().nullable(),
+ permanentUrl: z.string().url().nullable(),
+ storagePath: z.string().nullable(),
+ error: z.string().nullable(),
+});
+
+export const shortFormVideoJobSceneSchema = shortFormVideoAssetSchema
+ .omit({ id: true })
+ .extend({
+ id: z.string(),
+ jobId: z.string(),
+ startedAt: z.string().nullable(),
+ completedAt: z.string().nullable(),
+ });
+
+export const shortFormVideoCompositionAssetSchema = z.object({
+ id: z.string(),
+ jobId: z.string(),
+ planId: z.string(),
+ status: z.literal("done"),
+ aspectRatio: z.literal("9:16"),
+ resolution: shortFormVideoResolutionSchema,
+ durationSeconds: z.number().int().min(1).max(600),
+ permanentUrl: z.string().url(),
+ storagePath: z.string(),
+ error: z.null(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+});
+
+export const shortFormVideoCompositionSchema = z.object({
+ status: shortFormVideoCompositionStatusSchema,
+ finalAsset: shortFormVideoCompositionAssetSchema.nullable(),
+ error: z.string().nullable(),
+ startedAt: z.string().nullable(),
+ completedAt: z.string().nullable(),
+});
+
+export const shortFormVideoJobSchema = z.object({
+ id: z.string(),
+ planId: z.string(),
+ packetId: z.string().nullable(),
+ status: shortFormVideoJobStatusSchema,
+ provider: shortFormVideoProviderSchema,
+ model: z.string(),
+ request: z.record(z.string(), z.unknown()),
+ error: z.string().nullable(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ completedAt: z.string().nullable(),
+ composition: shortFormVideoCompositionSchema.nullable().default(null),
+ scenes: z.array(shortFormVideoJobSceneSchema),
+});
+
export const thumbnailChannelSchema = z.enum([
"youtube",
"instagram_feed",
@@ -215,6 +321,26 @@ export const shortFormPlanRequestSchema = z.object({
model: z.string().optional(),
});
+export const shortFormVideoRequestSchema = z.object({
+ plan: shortFormPlanSchema,
+ packet: contentPacketSchema.optional(),
+ imageAssets: z.array(assetImageSchema).default([]),
+ governanceResult: governanceResultSchema.nullable().optional(),
+ sceneIds: z.array(z.string()).optional(),
+ useReferenceImage: z.boolean().default(true),
+ aspectRatio: shortFormVideoAspectRatioSchema.optional(),
+ resolution: shortFormVideoResolutionSchema.default("480p"),
+ pollIntervalMs: z.number().int().min(500).max(10000).default(3000),
+ timeoutMs: z.number().int().min(1000).max(300000).default(120000),
+ model: z.string().optional(),
+ provider: shortFormVideoProviderSchema.default("xai"),
+});
+
+export const shortFormVideoCompositionRequestSchema = z.object({
+ voiceoverAudioUrl: z.string().url().optional(),
+ bgmAudioUrl: z.string().url().optional(),
+});
+
export type ContentChannel = z.infer;
export type ContentVariantFormat = z.infer;
export type ContentRiskLevel = z.infer;
@@ -228,12 +354,47 @@ export type GovernanceResult = z.infer;
export type AssetTargetFormat = z.infer;
export type AssetEditMode = z.infer;
export type AssetPlanStatus = z.infer;
+export type AssetImage = z.infer;
export type ShortFormPlatform = z.infer;
+export type ShortFormVideoStatus = z.infer;
+export type ShortFormVideoJobStatus = z.infer<
+ typeof shortFormVideoJobStatusSchema
+>;
+export type ShortFormVideoCompositionStatus = z.infer<
+ typeof shortFormVideoCompositionStatusSchema
+>;
+export type ShortFormVideoProvider = z.infer<
+ typeof shortFormVideoProviderSchema
+>;
+export type ShortFormVideoProviderMode = z.infer<
+ typeof shortFormVideoProviderModeSchema
+>;
+export type ShortFormVideoResolution = z.infer<
+ typeof shortFormVideoResolutionSchema
+>;
+export type ShortFormVideoAspectRatio = z.infer<
+ typeof shortFormVideoAspectRatioSchema
+>;
export type AssetPlan = z.infer;
export type ShortFormPlan = z.infer;
+export type ShortFormVideoAsset = z.infer;
+export type ShortFormVideoJobScene = z.infer<
+ typeof shortFormVideoJobSceneSchema
+>;
+export type ShortFormVideoCompositionAsset = z.infer<
+ typeof shortFormVideoCompositionAssetSchema
+>;
+export type ShortFormVideoComposition = z.infer<
+ typeof shortFormVideoCompositionSchema
+>;
+export type ShortFormVideoJob = z.infer;
export type ThumbnailChannel = z.infer;
export type GenerateThumbnailsRequest = z.infer<
typeof generateThumbnailsRequestSchema
>;
export type AssetPlanRequest = z.infer;
export type ShortFormPlanRequest = z.infer;
+export type ShortFormVideoRequest = z.infer;
+export type ShortFormVideoCompositionRequest = z.infer<
+ typeof shortFormVideoCompositionRequestSchema
+>;
diff --git a/packages/web/lib/supabase/types.ts b/packages/web/lib/supabase/types.ts
index bb90f74c..622b1039 100644
--- a/packages/web/lib/supabase/types.ts
+++ b/packages/web/lib/supabase/types.ts
@@ -515,6 +515,137 @@ export type Database = {
};
Relationships: [];
};
+ content_studio_video_jobs: {
+ Row: {
+ completed_at: string | null;
+ created_at: string;
+ created_by: string | null;
+ error: string | null;
+ id: string;
+ model: string;
+ packet: Json | null;
+ packet_id: string | null;
+ plan: Json;
+ plan_id: string;
+ provider: string;
+ request: Json;
+ status: string;
+ updated_at: string;
+ };
+ Insert: {
+ completed_at?: string | null;
+ created_at?: string;
+ created_by?: string | null;
+ error?: string | null;
+ id: string;
+ model: string;
+ packet?: Json | null;
+ packet_id?: string | null;
+ plan: Json;
+ plan_id: string;
+ provider?: string;
+ request: Json;
+ status: string;
+ updated_at?: string;
+ };
+ Update: {
+ completed_at?: string | null;
+ created_at?: string;
+ created_by?: string | null;
+ error?: string | null;
+ id?: string;
+ model?: string;
+ packet?: Json | null;
+ packet_id?: string | null;
+ plan?: Json;
+ plan_id?: string;
+ provider?: string;
+ request?: Json;
+ status?: string;
+ updated_at?: string;
+ };
+ Relationships: [];
+ };
+ content_studio_video_scenes: {
+ Row: {
+ aspect_ratio: string;
+ completed_at: string | null;
+ created_at: string;
+ duration_seconds: number;
+ error: string | null;
+ id: string;
+ job_id: string;
+ model: string;
+ permanent_url: string | null;
+ plan_id: string;
+ prompt: string;
+ provider: string;
+ request_id: string | null;
+ resolution: string;
+ scene_id: string;
+ scene_order: number;
+ started_at: string | null;
+ status: string;
+ storage_path: string | null;
+ temporary_url: string | null;
+ updated_at: string;
+ };
+ Insert: {
+ aspect_ratio: string;
+ completed_at?: string | null;
+ created_at?: string;
+ duration_seconds: number;
+ error?: string | null;
+ id: string;
+ job_id: string;
+ model: string;
+ permanent_url?: string | null;
+ plan_id: string;
+ prompt: string;
+ provider?: string;
+ request_id?: string | null;
+ resolution: string;
+ scene_id: string;
+ scene_order: number;
+ started_at?: string | null;
+ status: string;
+ storage_path?: string | null;
+ temporary_url?: string | null;
+ updated_at?: string;
+ };
+ Update: {
+ aspect_ratio?: string;
+ completed_at?: string | null;
+ created_at?: string;
+ duration_seconds?: number;
+ error?: string | null;
+ id?: string;
+ job_id?: string;
+ model?: string;
+ permanent_url?: string | null;
+ plan_id?: string;
+ prompt?: string;
+ provider?: string;
+ request_id?: string | null;
+ resolution?: string;
+ scene_id?: string;
+ scene_order?: number;
+ started_at?: string | null;
+ status?: string;
+ storage_path?: string | null;
+ temporary_url?: string | null;
+ updated_at?: string;
+ };
+ Relationships: [
+ {
+ foreignKeyName: "content_studio_video_scenes_job_id_fkey";
+ columns: ["job_id"];
+ isOneToOne: false;
+ referencedRelation: "content_studio_video_jobs";
+ referencedColumns: ["id"];
+ },
+ ];
+ };
credit_transactions: {
Row: {
action_type: string;
diff --git a/packages/web/package.json b/packages/web/package.json
index a9d9356d..7d62a857 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -11,6 +11,7 @@
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"generate:api": "orval --config orval.config.ts",
+ "content-studio:video:local": "bun scripts/content-studio-video-local.ts",
"test:visual": "playwright test tests/visual-qa.spec.ts",
"test": "vitest run",
"test:unit": "vitest run",
diff --git a/packages/web/scripts/content-studio-video-local.ts b/packages/web/scripts/content-studio-video-local.ts
new file mode 100644
index 00000000..54285b5c
--- /dev/null
+++ b/packages/web/scripts/content-studio-video-local.ts
@@ -0,0 +1,714 @@
+#!/usr/bin/env bun
+
+import { execFile } from "node:child_process";
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { parseArgs } from "node:util";
+import { promisify } from "node:util";
+import { createClient } from "@supabase/supabase-js";
+import dotenv from "dotenv";
+import { buildShortFormPlan } from "../lib/content-studio/assets/plan";
+import { buildShortFormSceneVideoPrompt } from "../lib/content-studio/assets/video-prompt";
+import { buildContentPacketFromPost } from "../lib/content-studio/packet-builder";
+import { fetchContentStudioPostDetail } from "../lib/content-studio/post-source";
+import {
+ contentPacketSchema,
+ shortFormPlatformSchema,
+ shortFormVideoResolutionSchema,
+ type ContentPacket,
+ type ShortFormPlatform,
+ type ShortFormVideoProviderMode,
+ type ShortFormVideoResolution,
+} from "../lib/content-studio/schemas";
+import type { Database } from "../lib/supabase/types";
+
+const XAI_VIDEOS_URL = "https://api.x.ai/v1/videos";
+const XAI_REFERENCE_VIDEO_MAX_SECONDS = 10;
+const execFileAsync = promisify(execFile);
+
+type XaiStartResponse = {
+ request_id?: string;
+};
+
+type XaiStatusResponse = {
+ status?: "pending" | "done" | "expired" | "failed";
+ video?: {
+ url?: string;
+ duration?: number;
+ };
+ error?: {
+ message?: string;
+ };
+};
+
+type SceneOutput = {
+ sceneId: string;
+ order: number;
+ part: number;
+ parts: number;
+ durationSeconds: number;
+ plannedSceneSeconds: number;
+ prompt: string;
+ mode: "reference-to-video" | "text-to-video";
+ requestId: string | null;
+ videoUrl: string | null;
+ file: string | null;
+};
+
+type DownloadedFile = {
+ url: string;
+ file: string;
+ contentType: string;
+};
+
+const { values } = parseArgs({
+ args: process.argv.slice(2),
+ options: {
+ packet: { type: "string" },
+ "post-id": { type: "string" },
+ "env-file": { type: "string" },
+ platform: { type: "string", default: "youtube_shorts" },
+ duration: { type: "string", default: "40" },
+ resolution: { type: "string", default: "480p" },
+ output: {
+ type: "string",
+ default: ".local/content-studio-videos",
+ },
+ scenes: { type: "string" },
+ model: { type: "string" },
+ "ffmpeg-path": { type: "string" },
+ "no-compose": { type: "boolean", default: false },
+ "compose-only": { type: "string" },
+ "poll-interval-ms": { type: "string", default: "3000" },
+ "timeout-ms": { type: "string", default: "300000" },
+ "dry-run": { type: "boolean", default: false },
+ help: { type: "boolean", default: false },
+ },
+ strict: true,
+ allowPositionals: false,
+});
+
+function usage() {
+ return `Usage:
+ bun run --filter @decoded/web content-studio:video:local -- \\
+ --post-id 9fba8cfa-8086-47e2-bc01-28944924ac9d
+
+Options:
+ --post-id Build ContentPacket from decoded Supabase post data.
+ Use "latest" to auto-pick the newest public-image post.
+ --packet Existing ContentPacket JSON exported from decoded data.
+ --env-file Env file to load. Defaults to packages/web/.env.local.
+ --duration Total planned duration. Defaults to 40.
+ --dry-run Write plan/prompts only; do not call xAI.
+ --no-compose Download clips only; skip final.mp4 composition.
+ --compose-only Compose an existing local output folder into final.mp4.
+ --ffmpeg-path ffmpeg binary path. Defaults to CONTENT_STUDIO_FFMPEG_PATH or ffmpeg.
+ --scenes scene_1,scene_3 Generate selected scenes only.
+ --output Local output directory.
+
+Notes:
+ - Source images come from decoded data: packet.sourceImage or the post image_url.
+ - xAI reference images must be public http(s) URLs. Localhost or relative URLs
+ are rejected because identity-preserving reference-to-video cannot fetch them.
+`;
+}
+
+function stringValue(name: keyof typeof values): string | undefined {
+ const value = values[name];
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function defaultEnvPath() {
+ const cwdEnv = path.resolve(process.cwd(), ".env.local");
+ const marker = `${path.sep}.worktrees${path.sep}`;
+ const markerIndex = process.cwd().indexOf(marker);
+ if (markerIndex === -1) return cwdEnv;
+
+ const mainRepoRoot = process.cwd().slice(0, markerIndex);
+ return path.join(mainRepoRoot, "packages", "web", ".env.local");
+}
+
+const envPath = stringValue("env-file") ?? defaultEnvPath();
+dotenv.config({ path: envPath, override: true, quiet: true });
+
+function numberValue(name: keyof typeof values, fallback: number): number {
+ const raw = stringValue(name);
+ if (!raw) return fallback;
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`Invalid number for --${String(name)}: ${raw}`);
+ }
+ return parsed;
+}
+
+function isXaiFetchableImageUrl(value: string | undefined): value is string {
+ if (!value) return false;
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
+ const hostname = url.hostname.toLowerCase();
+ return !(
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "0.0.0.0" ||
+ hostname === "::1"
+ );
+ } catch {
+ return false;
+ }
+}
+
+function slug(value: string) {
+ return (
+ value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80) || "content-studio-video"
+ );
+}
+
+async function loadPacket(): Promise {
+ const packetPath = stringValue("packet");
+ if (packetPath) {
+ const raw = await readFile(packetPath, "utf8");
+ return contentPacketSchema.parse(JSON.parse(raw));
+ }
+
+ const requestedPostId = stringValue("post-id") ?? "latest";
+
+ const supabaseUrl = process.env.NEXT_PUBLIC_DATABASE_API_URL;
+ const supabaseKey =
+ process.env.DATABASE_SERVICE_ROLE_KEY ??
+ process.env.NEXT_PUBLIC_DATABASE_ANON_KEY;
+ if (!supabaseUrl || !supabaseKey) {
+ throw new Error(
+ `Missing Supabase env in ${envPath}: NEXT_PUBLIC_DATABASE_API_URL and DATABASE_SERVICE_ROLE_KEY or NEXT_PUBLIC_DATABASE_ANON_KEY are required`
+ );
+ }
+
+ const supabase = createClient(supabaseUrl, supabaseKey, {
+ auth: { persistSession: false },
+ });
+ const postId =
+ requestedPostId === "latest"
+ ? await resolveLatestPostId(supabase)
+ : requestedPostId;
+ const post = await fetchContentStudioPostDetail(supabase, postId);
+ if (!post) throw new Error(`Post not found: ${postId}`);
+ return buildContentPacketFromPost(post);
+}
+
+async function resolveLatestPostId(
+ supabase: ReturnType>
+): Promise {
+ const { data, error } = await supabase
+ .from("posts")
+ .select("id, image_url, created_at")
+ .not("image_url", "is", null)
+ .order("created_at", { ascending: false })
+ .limit(50);
+
+ if (error) throw new Error(error.message);
+ const row = (data ?? []).find((post) =>
+ isXaiFetchableImageUrl(post.image_url ?? undefined)
+ );
+ if (!row) {
+ throw new Error("No local decoded post has an xAI-fetchable image_url.");
+ }
+ return row.id;
+}
+
+async function startXaiVideo(input: {
+ apiKey: string;
+ model: string;
+ prompt: string;
+ durationSeconds: number;
+ resolution: ShortFormVideoResolution;
+ referenceImageUrl?: string;
+}) {
+ const body: Record = {
+ model: input.model,
+ prompt: input.prompt,
+ duration: Math.min(
+ Math.max(input.durationSeconds, 1),
+ XAI_REFERENCE_VIDEO_MAX_SECONDS
+ ),
+ aspect_ratio: "9:16",
+ resolution: input.resolution,
+ };
+
+ if (input.referenceImageUrl) {
+ body.reference_images = [{ url: input.referenceImageUrl }];
+ }
+
+ const response = await fetch(`${XAI_VIDEOS_URL}/generations`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${input.apiKey}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ `xAI video request failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}`
+ );
+ }
+
+ const data = (await response.json()) as XaiStartResponse;
+ if (!data.request_id) throw new Error("xAI video response missing request_id");
+ return data.request_id;
+}
+
+async function pollXaiVideo(input: {
+ apiKey: string;
+ requestId: string;
+ pollIntervalMs: number;
+ timeoutMs: number;
+}) {
+ const deadline = Date.now() + input.timeoutMs;
+ while (Date.now() <= deadline) {
+ const response = await fetch(`${XAI_VIDEOS_URL}/${input.requestId}`, {
+ headers: { Authorization: `Bearer ${input.apiKey}` },
+ });
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ `xAI video polling failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}`
+ );
+ }
+
+ const data = (await response.json()) as XaiStatusResponse;
+ if (
+ data.status === "done" ||
+ data.status === "expired" ||
+ data.status === "failed"
+ ) {
+ return data;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, input.pollIntervalMs));
+ }
+
+ throw new Error("xAI video polling timed out");
+}
+
+async function downloadVideo(url: string, filePath: string) {
+ await downloadUrl(url, filePath);
+}
+
+function extensionFromContentType(contentType: string) {
+ if (contentType.includes("png")) return "png";
+ if (contentType.includes("webp")) return "webp";
+ if (contentType.includes("gif")) return "gif";
+ if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
+ if (contentType.includes("webm")) return "webm";
+ if (contentType.includes("mp4")) return "mp4";
+ return "bin";
+}
+
+async function downloadUrl(
+ url: string,
+ filePath: string
+): Promise {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Download failed: ${response.status}`);
+ }
+ const contentType =
+ response.headers.get("content-type") ?? "application/octet-stream";
+ await writeFile(filePath, Buffer.from(await response.arrayBuffer()));
+ return { url, file: filePath, contentType };
+}
+
+function splitSceneDuration(seconds: number) {
+ const chunks: number[] = [];
+ let remaining = Math.max(1, Math.round(seconds));
+ while (remaining > 0) {
+ const duration = Math.min(remaining, XAI_REFERENCE_VIDEO_MAX_SECONDS);
+ chunks.push(duration);
+ remaining -= duration;
+ }
+ return chunks;
+}
+
+function withPartInstruction(prompt: string, part: number, parts: number) {
+ if (parts === 1) return prompt;
+ return `${prompt}
+
+Scene continuation:
+- This is part ${part} of ${parts} for the same editorial scene.
+- Keep the same source-image identity, face structure, outfit, styling, camera language, and typography-safe composition.
+- Do not introduce a new person, new outfit, different hairstyle, or alternate identity between parts.`;
+}
+
+function compositionSize(resolution: ShortFormVideoResolution) {
+ switch (resolution) {
+ case "720p":
+ return { width: 720, height: 1280 };
+ case "480p":
+ default:
+ return { width: 480, height: 854 };
+ }
+}
+
+function buildLocalCompositionArgs(input: {
+ clipFiles: string[];
+ outputPath: string;
+ resolution: ShortFormVideoResolution;
+}) {
+ if (input.clipFiles.length === 0) {
+ throw new Error("No downloaded scene clips available for composition");
+ }
+
+ const { width, height } = compositionSize(input.resolution);
+ const args = ["-y"];
+ for (const file of input.clipFiles) args.push("-i", file);
+
+ const filters = input.clipFiles.map(
+ (_, index) =>
+ `[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1,fps=30,format=yuv420p[v${index}]`
+ );
+ const videoLabel =
+ input.clipFiles.length === 1
+ ? "[v0]"
+ : `${input.clipFiles.map((_, index) => `[v${index}]`).join("")}concat=n=${input.clipFiles.length}:v=1:a=0[vout]`;
+
+ if (input.clipFiles.length === 1) {
+ filters.push(`${videoLabel}null[vout]`);
+ } else {
+ filters.push(videoLabel);
+ }
+
+ args.push(
+ "-filter_complex",
+ filters.join(";"),
+ "-map",
+ "[vout]",
+ "-an",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "21",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ input.outputPath
+ );
+ return args;
+}
+
+async function composeFinalVideo(input: {
+ clipFiles: string[];
+ outputPath: string;
+ resolution: ShortFormVideoResolution;
+ ffmpegPath: string;
+}) {
+ const args = buildLocalCompositionArgs(input);
+ await execFileAsync(input.ffmpegPath, args, {
+ timeout: 10 * 60 * 1000,
+ maxBuffer: 1024 * 1024 * 4,
+ });
+}
+
+async function writeCompositionMetadata(input: {
+ outputDir: string;
+ finalVideoFile: string | null;
+ clipFiles: string[];
+ ffmpegPath: string;
+ resolution: ShortFormVideoResolution;
+ error?: string;
+}) {
+ await writeFile(
+ path.join(input.outputDir, "composition.json"),
+ JSON.stringify(
+ {
+ file: input.finalVideoFile,
+ clips: input.clipFiles,
+ ffmpegPath: input.ffmpegPath,
+ resolution: input.resolution,
+ ...(input.error ? { error: input.error } : {}),
+ },
+ null,
+ 2
+ )
+ );
+}
+
+async function loadClipFilesFromOutputDir(outputDir: string) {
+ const raw = await readFile(path.join(outputDir, "outputs.json"), "utf8");
+ const outputs = JSON.parse(raw) as SceneOutput[];
+ return outputs
+ .filter((output) => output.file)
+ .sort((a, b) => a.order - b.order || a.part - b.part)
+ .map((output) => output.file as string);
+}
+
+async function hasOutputsFile(outputDir: string) {
+ try {
+ await access(path.join(outputDir, "outputs.json"));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function resolveComposeOnlyDir(outputDir: string) {
+ if (await hasOutputsFile(outputDir)) return outputDir;
+
+ const packagePrefix = `packages${path.sep}web${path.sep}`;
+ if (!path.isAbsolute(outputDir) && outputDir.startsWith(packagePrefix)) {
+ const packageRelativeDir = outputDir.slice(packagePrefix.length);
+ if (await hasOutputsFile(packageRelativeDir)) return packageRelativeDir;
+ }
+
+ return outputDir;
+}
+
+async function composeExistingOutputDir(input: {
+ outputDir: string;
+ resolution: ShortFormVideoResolution;
+ ffmpegPath: string;
+}) {
+ const outputDir = await resolveComposeOnlyDir(input.outputDir);
+ const clipFiles = await loadClipFilesFromOutputDir(outputDir);
+ const finalVideoFile = path.join(outputDir, "final.mp4");
+ console.log(`Composing final video ${finalVideoFile}`);
+ await composeFinalVideo({
+ clipFiles,
+ outputPath: finalVideoFile,
+ resolution: input.resolution,
+ ffmpegPath: input.ffmpegPath,
+ });
+ await writeCompositionMetadata({
+ outputDir,
+ finalVideoFile,
+ clipFiles,
+ ffmpegPath: input.ffmpegPath,
+ resolution: input.resolution,
+ });
+ console.log(`Composed ${finalVideoFile}`);
+}
+
+async function main() {
+ if (values.help) {
+ console.log(usage());
+ return;
+ }
+
+ const resolution = shortFormVideoResolutionSchema.parse(
+ stringValue("resolution")
+ );
+ const ffmpegPath =
+ stringValue("ffmpeg-path") ??
+ process.env.CONTENT_STUDIO_FFMPEG_PATH ??
+ "ffmpeg";
+ const composeOnlyDir = stringValue("compose-only");
+ if (composeOnlyDir) {
+ await composeExistingOutputDir({
+ outputDir: composeOnlyDir,
+ resolution,
+ ffmpegPath,
+ });
+ return;
+ }
+
+ const packet = await loadPacket();
+ const platform = shortFormPlatformSchema.parse(stringValue("platform"));
+ const durationSeconds = numberValue("duration", 20);
+ const outputRoot = stringValue("output") ?? ".local/content-studio-videos";
+ const outputDir = path.join(outputRoot, `${slug(packet.title)}-${Date.now()}`);
+ const model =
+ stringValue("model") ??
+ process.env.CONTENT_STUDIO_VIDEO_MODEL ??
+ "grok-imagine-video";
+ const sourceImage = isXaiFetchableImageUrl(packet.sourceImage)
+ ? packet.sourceImage
+ : undefined;
+ if (!sourceImage) {
+ throw new Error(
+ `Post sourceImage is not xAI-fetchable: ${packet.sourceImage}. Use a decoded post whose image_url is a public http(s) URL.`
+ );
+ }
+ const sceneFilter = stringValue("scenes")
+ ?.split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const selectedSceneIds = sceneFilter?.length ? new Set(sceneFilter) : null;
+ const dryRun = Boolean(values["dry-run"]);
+ const apiKey = process.env.XAI_API_KEY;
+ const pollIntervalMs = numberValue("poll-interval-ms", 3000);
+ const timeoutMs = numberValue("timeout-ms", 300000);
+ const shouldCompose = !Boolean(values["no-compose"]);
+
+ if (!dryRun && !apiKey) {
+ throw new Error("XAI_API_KEY is required unless --dry-run is set");
+ }
+
+ const plan = buildShortFormPlan({
+ packet,
+ platform: platform as ShortFormPlatform,
+ durationSeconds,
+ variants: [],
+ useResearchInCopy: false,
+ });
+ const scenes = plan.scenes.filter(
+ (scene) => !selectedSceneIds || selectedSceneIds.has(scene.id)
+ );
+ const mode: ShortFormVideoProviderMode = "reference-to-video";
+ const promptInput = {
+ source: "packet-source" as const,
+ mode,
+ };
+ const outputs: SceneOutput[] = scenes.flatMap((scene) => {
+ const scenePrompt = buildShortFormSceneVideoPrompt(plan, scene, promptInput);
+ const parts = splitSceneDuration(scene.seconds);
+ return parts.map((partDuration, index) => ({
+ sceneId: scene.id,
+ order: scene.order,
+ part: index + 1,
+ parts: parts.length,
+ durationSeconds: partDuration,
+ plannedSceneSeconds: scene.seconds,
+ prompt: withPartInstruction(scenePrompt, index + 1, parts.length),
+ mode,
+ requestId: null,
+ videoUrl: null,
+ file: null,
+ }));
+ });
+
+ await mkdir(outputDir, { recursive: true });
+ const sourceHead = await fetch(sourceImage, { method: "HEAD" }).catch(
+ () => null
+ );
+ const sourceContentType =
+ sourceHead?.ok && sourceHead.headers.get("content-type")
+ ? sourceHead.headers.get("content-type")
+ : "image/jpeg";
+ const sourceImageFile = path.join(
+ outputDir,
+ `source_image.${extensionFromContentType(sourceContentType ?? "image/jpeg")}`
+ );
+ const sourceImageDownload = await downloadUrl(sourceImage, sourceImageFile);
+
+ await writeFile(
+ path.join(outputDir, "plan.json"),
+ JSON.stringify(
+ {
+ packet,
+ plan,
+ sourceImage: sourceImageDownload,
+ },
+ null,
+ 2
+ )
+ );
+ await writeFile(
+ path.join(outputDir, "prompts.json"),
+ JSON.stringify(outputs, null, 2)
+ );
+ console.log(`Downloaded source image ${sourceImageFile}`);
+
+ if (dryRun) {
+ console.log(`Wrote plan and prompts to ${outputDir}`);
+ return;
+ }
+
+ for (const output of outputs) {
+ const scene = scenes.find((candidate) => candidate.id === output.sceneId);
+ if (!scene) continue;
+ const partLabel =
+ output.parts > 1 ? ` part ${output.part}/${output.parts}` : "";
+ console.log(
+ `Starting ${output.sceneId}${partLabel} (${output.durationSeconds}s of ${scene.seconds}s)`
+ );
+ const requestId = await startXaiVideo({
+ apiKey: apiKey as string,
+ model,
+ prompt: output.prompt,
+ durationSeconds: output.durationSeconds,
+ resolution,
+ referenceImageUrl: sourceImage,
+ });
+ output.requestId = requestId;
+
+ const result = await pollXaiVideo({
+ apiKey: apiKey as string,
+ requestId,
+ pollIntervalMs,
+ timeoutMs,
+ });
+ if (result.status !== "done" || !result.video?.url) {
+ throw new Error(
+ `${output.sceneId} failed: ${
+ result.error?.message ?? result.status ?? "unknown status"
+ }`
+ );
+ }
+
+ output.videoUrl = result.video.url;
+ const fileName =
+ output.parts > 1
+ ? `${output.sceneId}_part_${output.part}_${requestId}.mp4`
+ : `${output.sceneId}_${requestId}.mp4`;
+ const file = path.join(outputDir, fileName);
+ await downloadVideo(result.video.url, file);
+ output.file = file;
+ await writeFile(
+ path.join(outputDir, "outputs.json"),
+ JSON.stringify(outputs, null, 2)
+ );
+ console.log(`Downloaded ${file}`);
+ }
+
+ const clipFiles = outputs
+ .map((output) => output.file)
+ .filter((file): file is string => Boolean(file));
+ if (shouldCompose) {
+ const finalVideoFile = path.join(outputDir, "final.mp4");
+ try {
+ console.log(`Composing final video ${finalVideoFile}`);
+ await composeFinalVideo({
+ clipFiles,
+ outputPath: finalVideoFile,
+ resolution,
+ ffmpegPath,
+ });
+ await writeCompositionMetadata({
+ outputDir,
+ finalVideoFile,
+ clipFiles,
+ ffmpegPath,
+ resolution,
+ });
+ console.log(`Composed ${finalVideoFile}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ await writeCompositionMetadata({
+ outputDir,
+ finalVideoFile: null,
+ clipFiles,
+ ffmpegPath,
+ resolution,
+ error: message,
+ });
+ console.warn(
+ `Final composition skipped: ${message}. Install ffmpeg or pass --ffmpeg-path, then compose clips listed in composition.json.`
+ );
+ }
+ }
+
+ console.log(`Done: ${outputDir}`);
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
diff --git a/scripts/check-schema-drift.ts b/scripts/check-schema-drift.ts
new file mode 100644
index 00000000..c3c9206e
--- /dev/null
+++ b/scripts/check-schema-drift.ts
@@ -0,0 +1,271 @@
+#!/usr/bin/env bun
+/**
+ * Schema drift PR gate (#373 v2)
+ *
+ * TypeScript 상수 ↔ Postgres CHECK constraint 값 집합을 set-equality 비교한다.
+ *
+ * 동작:
+ * 1. supabase/migrations/*.sql 를 timestamp 순서로 psql 에 적용 ($LOCAL_DATABASE_URL)
+ * 2. 각 SOT_MAPPINGS entry 에 대해:
+ * - ts-morph 로 TS 상수 (`export const X = [...] as const`) 값 set 추출
+ * - psql 로 pg_get_constraintdef 출력 가져와 정규식으로 set 추출
+ * - set 비교 → 결과 markdown 표 누적
+ * 3. drift 1건 이상이면 exit 1
+ *
+ * stdout 은 markdown report (PR comment 로 그대로 사용).
+ * stderr 는 진단 로그.
+ *
+ * SOT 등록: docs/database/schema-drift-sot.md
+ */
+
+import { spawnSync } from "node:child_process";
+import { existsSync, readdirSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { Project, SyntaxKind } from "ts-morph";
+
+// ---------- paths ----------
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const REPO_ROOT = resolve(__dirname, "..");
+const MIGRATIONS_DIR = join(REPO_ROOT, "supabase", "migrations");
+
+// ---------- SOT mapping (sync with docs/database/schema-drift-sot.md) ----------
+
+interface SotEntry {
+ label: string; // human-readable id for report
+ tsFile: string; // path relative to REPO_ROOT
+ tsConstName: string; // exported const name in tsFile
+ table: string; // schema-qualified table
+ column: string; // column the constraint guards
+ constraint: string; // pg constraint name
+}
+
+const SOT_MAPPINGS: SotEntry[] = [
+ {
+ label: "post_magazines.status",
+ tsFile: "packages/web/lib/api/admin/magazines.ts",
+ tsConstName: "MAGAZINE_STATUSES",
+ table: "public.post_magazines",
+ column: "status",
+ constraint: "post_magazines_status_check",
+ },
+];
+
+// ---------- types ----------
+
+type CheckResult =
+ | { kind: "ok"; values: string[] }
+ | { kind: "drift"; ts: string[]; db: string[]; tsOnly: string[]; dbOnly: string[] }
+ | { kind: "parse_fail"; raw: string; ts: string[] }
+ | { kind: "error"; message: string };
+
+// ---------- helpers ----------
+
+function log(...args: unknown[]): void {
+ // diagnostic logs go to stderr so stdout stays clean markdown
+ console.error("[drift]", ...args);
+}
+
+function runPsql(args: string[], stdin?: string): { stdout: string; stderr: string; code: number } {
+ const url = process.env.LOCAL_DATABASE_URL;
+ if (!url) {
+ throw new Error("LOCAL_DATABASE_URL is not set");
+ }
+ const res = spawnSync("psql", [url, "-X", "-A", "-t", ...args], {
+ input: stdin,
+ encoding: "utf8",
+ });
+ return {
+ stdout: res.stdout ?? "",
+ stderr: res.stderr ?? "",
+ code: res.status ?? 1,
+ };
+}
+
+function applyMigrations(): void {
+ const files = readdirSync(MIGRATIONS_DIR)
+ .filter((f) => f.endsWith(".sql"))
+ .sort(); // timestamp-prefixed filenames sort lexicographically
+ log(`applying ${files.length} migrations from ${MIGRATIONS_DIR}`);
+ for (const f of files) {
+ const path = join(MIGRATIONS_DIR, f);
+ const res = spawnSync(
+ "psql",
+ [process.env.LOCAL_DATABASE_URL!, "-X", "-v", "ON_ERROR_STOP=1", "-f", path],
+ { encoding: "utf8" },
+ );
+ if (res.status !== 0) {
+ throw new Error(
+ `migration failed: ${f}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`,
+ );
+ }
+ }
+ log("migrations applied OK");
+}
+
+function extractTsValues(tsFile: string, constName: string): string[] {
+ const abs = join(REPO_ROOT, tsFile);
+ if (!existsSync(abs)) {
+ throw new Error(`TS file not found: ${tsFile}`);
+ }
+ const project = new Project({ useInMemoryFileSystem: false });
+ const src = project.addSourceFileAtPath(abs);
+ const decl = src.getVariableDeclaration(constName);
+ if (!decl) {
+ throw new Error(`const ${constName} not found in ${tsFile}`);
+ }
+ // expect `as const` assertion around an ArrayLiteralExpression
+ const init = decl.getInitializer();
+ if (!init) {
+ throw new Error(`const ${constName} has no initializer`);
+ }
+ // peel `as const`
+ const arrayLit =
+ init.getKind() === SyntaxKind.AsExpression
+ ? init.asKindOrThrow(SyntaxKind.AsExpression).getExpression()
+ : init;
+ if (arrayLit.getKind() !== SyntaxKind.ArrayLiteralExpression) {
+ throw new Error(`const ${constName} initializer is not an array literal`);
+ }
+ const elements = arrayLit.asKindOrThrow(SyntaxKind.ArrayLiteralExpression).getElements();
+ const values: string[] = [];
+ for (const el of elements) {
+ if (el.getKind() !== SyntaxKind.StringLiteral) {
+ throw new Error(`const ${constName} has non-string element: ${el.getText()}`);
+ }
+ values.push(el.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText());
+ }
+ return values;
+}
+
+function extractDbValues(constraint: string): { values: string[] | null; raw: string } {
+ // pg_get_constraintdef returns a textual definition. We coerce schema-qualified
+ // and bare names by matching on conname only across all schemas.
+ const query = `SELECT pg_get_constraintdef(c.oid)
+FROM pg_constraint c
+WHERE c.conname = '${constraint}'
+LIMIT 1;`;
+ const res = runPsql(["-c", query]);
+ if (res.code !== 0) {
+ throw new Error(`psql failed: ${res.stderr}`);
+ }
+ const raw = res.stdout.trim();
+ if (!raw) {
+ throw new Error(`constraint not found in DB: ${constraint}`);
+ }
+ // Three accepted forms (see docs/database/schema-drift-sot.md):
+ // CHECK ((col)::text = ANY ((ARRAY['a'::text, 'b'::text])::text[]))
+ // CHECK (col = ANY (ARRAY['a'::text, 'b'::text]))
+ // CHECK (col IN ('a','b','c'))
+ // Strategy: extract literal strings inside the first ARRAY[...] or IN (...).
+ const arrayMatch = raw.match(/ARRAY\[([^\]]+)\]/);
+ const inMatch = !arrayMatch ? raw.match(/IN\s*\(([^)]+)\)/i) : null;
+ const payload = arrayMatch?.[1] ?? inMatch?.[1];
+ if (!payload) {
+ return { values: null, raw };
+ }
+ // tokenize quoted string literals, strip ::text casts
+ const literals = Array.from(payload.matchAll(/'((?:[^']|'')*)'/g)).map((m) =>
+ m[1].replace(/''/g, "'"),
+ );
+ return { values: literals, raw };
+}
+
+function diff(ts: string[], db: string[]): { tsOnly: string[]; dbOnly: string[] } {
+ const tsSet = new Set(ts);
+ const dbSet = new Set(db);
+ return {
+ tsOnly: ts.filter((v) => !dbSet.has(v)).sort(),
+ dbOnly: db.filter((v) => !tsSet.has(v)).sort(),
+ };
+}
+
+// ---------- main ----------
+
+function main(): number {
+ const out: string[] = [];
+ out.push("## Schema drift report (#373 v2)\n");
+
+ try {
+ applyMigrations();
+ } catch (e) {
+ out.push(`### Setup failed\n\n\`\`\`\n${(e as Error).message}\n\`\`\`\n`);
+ process.stdout.write(out.join("\n"));
+ return 1;
+ }
+
+ const results: { entry: SotEntry; result: CheckResult }[] = [];
+ for (const entry of SOT_MAPPINGS) {
+ log(`checking ${entry.label}`);
+ try {
+ const tsValues = extractTsValues(entry.tsFile, entry.tsConstName);
+ const { values: dbValues, raw } = extractDbValues(entry.constraint);
+ if (dbValues === null) {
+ results.push({
+ entry,
+ result: { kind: "parse_fail", raw, ts: tsValues },
+ });
+ continue;
+ }
+ const { tsOnly, dbOnly } = diff(tsValues, dbValues);
+ if (tsOnly.length === 0 && dbOnly.length === 0) {
+ results.push({
+ entry,
+ result: { kind: "ok", values: [...tsValues].sort() },
+ });
+ } else {
+ results.push({
+ entry,
+ result: {
+ kind: "drift",
+ ts: [...tsValues].sort(),
+ db: [...dbValues].sort(),
+ tsOnly,
+ dbOnly,
+ },
+ });
+ }
+ } catch (e) {
+ results.push({ entry, result: { kind: "error", message: (e as Error).message } });
+ }
+ }
+
+ // summary
+ const driftCount = results.filter(
+ (r) => r.result.kind === "drift" || r.result.kind === "parse_fail" || r.result.kind === "error",
+ ).length;
+ if (driftCount === 0) {
+ out.push(`OK — ${results.length} SOT entries match.\n`);
+ } else {
+ out.push(`**${driftCount} of ${results.length} SOT entries failed.**\n`);
+ }
+ out.push("| SOT | Status | Detail |");
+ out.push("|---|---|---|");
+ for (const { entry, result } of results) {
+ if (result.kind === "ok") {
+ out.push(`| ${entry.label} | OK | \`${result.values.join("`, `")}\` |`);
+ } else if (result.kind === "drift") {
+ const tsOnly = result.tsOnly.length ? `TS-only: \`${result.tsOnly.join("`, `")}\`` : "";
+ const dbOnly = result.dbOnly.length ? `DB-only: \`${result.dbOnly.join("`, `")}\`` : "";
+ out.push(`| ${entry.label} | DRIFT | ${[tsOnly, dbOnly].filter(Boolean).join("; ")} |`);
+ } else if (result.kind === "parse_fail") {
+ out.push(
+ `| ${entry.label} | PARSE_FAIL | raw: \`${result.raw.replace(/\|/g, "\\|")}\` |`,
+ );
+ } else {
+ out.push(`| ${entry.label} | ERROR | ${result.message.replace(/\n/g, " ")} |`);
+ }
+ }
+ out.push("");
+ out.push("---");
+ out.push(
+ "SOT mapping: [`docs/database/schema-drift-sot.md`](docs/database/schema-drift-sot.md) · " +
+ "Bypass via `drift-bypass` label.",
+ );
+ process.stdout.write(out.join("\n") + "\n");
+ return driftCount === 0 ? 0 : 1;
+}
+
+process.exit(main());
diff --git a/supabase/migrations/20260514120000_content_studio_video_jobs.sql b/supabase/migrations/20260514120000_content_studio_video_jobs.sql
new file mode 100644
index 00000000..11f0fd75
--- /dev/null
+++ b/supabase/migrations/20260514120000_content_studio_video_jobs.sql
@@ -0,0 +1,64 @@
+-- operation: create Content Studio video job persistence
+--
+-- Why: Issue #508 Phase 1B needs xAI scene video generation to survive refreshes,
+-- support per-scene retry, and avoid one long synchronous API request.
+-- The UI still remains operator/admin-only.
+--
+-- Access model:
+-- - Route handlers verify admin status with cookie session.
+-- - Route handlers use service_role for table writes and Storage upload.
+-- - RLS is enabled with no direct client policies; service_role bypasses RLS.
+
+CREATE TABLE IF NOT EXISTS public.content_studio_video_jobs (
+ id text PRIMARY KEY,
+ plan_id text NOT NULL,
+ packet_id text,
+ status text NOT NULL CHECK (status IN ('pending', 'running', 'partial', 'done', 'failed', 'expired')),
+ provider text NOT NULL DEFAULT 'xai' CHECK (provider = 'xai'),
+ model text NOT NULL,
+ request jsonb NOT NULL,
+ plan jsonb NOT NULL,
+ packet jsonb,
+ error text,
+ created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ completed_at timestamptz
+);
+
+CREATE TABLE IF NOT EXISTS public.content_studio_video_scenes (
+ id text PRIMARY KEY,
+ job_id text NOT NULL REFERENCES public.content_studio_video_jobs(id) ON DELETE CASCADE,
+ plan_id text NOT NULL,
+ scene_id text NOT NULL,
+ scene_order integer NOT NULL,
+ status text NOT NULL CHECK (status IN ('pending', 'running', 'done', 'expired', 'failed')),
+ provider text NOT NULL DEFAULT 'xai' CHECK (provider = 'xai'),
+ model text NOT NULL,
+ request_id text,
+ prompt text NOT NULL,
+ duration_seconds integer NOT NULL CHECK (duration_seconds BETWEEN 1 AND 15),
+ aspect_ratio text NOT NULL,
+ resolution text NOT NULL,
+ temporary_url text,
+ permanent_url text,
+ storage_path text,
+ error text,
+ started_at timestamptz,
+ completed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (job_id, scene_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_content_studio_video_jobs_plan_id
+ ON public.content_studio_video_jobs(plan_id);
+
+CREATE INDEX IF NOT EXISTS idx_content_studio_video_jobs_status_updated
+ ON public.content_studio_video_jobs(status, updated_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_content_studio_video_scenes_job_order
+ ON public.content_studio_video_scenes(job_id, scene_order);
+
+ALTER TABLE public.content_studio_video_jobs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.content_studio_video_scenes ENABLE ROW LEVEL SECURITY;