From 5e8d00e8f13f0ab904bc941b97db1a1811a0f856 Mon Sep 17 00:00:00 2001 From: Kiyori <113906780+thxforall@users.noreply.github.com> Date: Thu, 28 May 2026 15:20:42 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(ci):=20schema=20drift=20PR=20gate=20(T?= =?UTF-8?q?S<->DB)=20=E2=80=94=20#373=20v2=20(#588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(database): add schema-drift-sot.md SSOT mapping (#373 v2) TS 상수 ↔ DB CHECK constraint 매핑 SSOT. scripts/check-schema-drift.ts 의 SOT_MAPPINGS 와 함께 갱신한다. v2 PR gate 의 동작/허용 constraint 형식/ 의도적 우회(drift-bypass label)/v3 후속 항목(posts.status, generated types) 명시. * feat(scripts): add check-schema-drift.ts for TS<->DB enum drift (#373 v2) ts-morph 로 TS 상수 (`export const X = [...] as const`) 값 set 추출 → psql 로 pg_get_constraintdef 파싱 → set-equality 비교. 세 가지 CHECK constraint 형식 (status::text=ANY ARRAY, col=ANY ARRAY, IN list) 지원. drift 1건 이상이면 exit 1, stdout 은 PR comment 용 markdown report. devDependency: ts-morph@^24.0.0 추가. * feat(ci): add schema-drift.yml PR gate workflow (#373 v2) postgres:17 service + postgresql-client-17 + bun install + supabase migrations 순차 적용 + check-schema-drift.ts 실행 + sticky PR comment (marocchino/sticky-pull-request-comment@v2, header=schema-drift). drift-bypass label: continue-on-error 로 job green 유지 (comment 는 유지). branch protection 별도 설정 필요. * docs(database): drift-check.md mark v2 PR gate landed (#373) v1 nightly 와 v2 PR gate 채널을 분리 명시. v2 세부는 schema-drift-sot.md 에서 별도 owning. --- .github/workflows/schema-drift.yml | 73 ++++++++ bun.lock | 15 +- docs/database/drift-check.md | 12 +- docs/database/schema-drift-sot.md | 69 ++++++++ package.json | 1 + scripts/check-schema-drift.ts | 271 +++++++++++++++++++++++++++++ 6 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/schema-drift.yml create mode 100644 docs/database/schema-drift-sot.md create mode 100644 scripts/check-schema-drift.ts 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/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/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/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/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()); From 96df1e5357f6a8d98e5a5670ea8a73947cfd0ee8 Mon Sep 17 00:00:00 2001 From: Kiyori <113906780+thxforall@users.noreply.github.com> Date: Thu, 28 May 2026 15:20:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs(design-system):=20v2.2.0=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=E2=80=94=20content=20fundamentals,=20anim?= =?UTF-8?q?ations,=20dual=20palette=20(#589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(design-system): bump README to v2.2.0 with content fundamentals + dual palette - Version: 2.1.0 -> 2.2.0, Last Updated: 2026-05-28 - Add v2.2 변경사항: Content Fundamentals, Animations 카탈로그, Dual Palette, Magazine palette tokens (#573), Navigation Facts, Home sequence, IBM Plex Mono (inline), DecodedLogo WebGL - Document Index: 신규 Foundation (v2.2) 섹션 — content-fundamentals.md, animations.md 추가 - Tech Stack table 확장: OKLCH + Hex magazine palette, typography 4종, three.js + @chenglou/pretext (DecodedLogo) - Navigation Facts + Home Page Sequence 섹션 추가 (app/page.tsx:568 앵커) Closes #572 (part 1/7). Co-Authored-By: Claude Opus 4.7 (1M context) * docs(design-system): add content-fundamentals.md 7 sections covering Voice / Casing / Person / Language / Pricing / Numbers / Emoji policy with Do/Don't rules and code anchors: - Pricing: ko-KR locale + ₩ prefix, toLocaleString (앵커: DecodedSolutionsSection.tsx:40 — 마이그레이션 대상) - Numbers: 사용자 표면은 toLocaleString 통과 (앵커: lib/utils/format.ts:36) - Voice: editorial(큐레이션) vs product UI(명료) 분리 - Language: ko-KR 기본, 고유명사·기술 용어 원문 유지 - Emoji: editorial 표면 금지, product UI 절제, Lucide 우선 Closes #572 (part 2/7). Co-Authored-By: Claude Opus 4.7 (1M context) * docs(design-system): add animations.md catalog 15+ CSS keyframe catalog with file/line anchors from packages/web/app/globals.css: - dash-flow (L299), hologram-* family (L318-L351), spot-* family (L410-L458), slide-up (L487), shimmer (L503), ai-summary-* (L522-L535), card-glow (L551), marquee-c (L585) - duration/easing/용도/reduce-motion 컬럼 - Easing 함수 정리: ease-out (단방향 reveal), ease-in-out (펄스), linear (무한 flow), cubic-bezier(0.22, 1, 0.36, 1) — observe pattern, cubic-bezier(0.16, 1, 0.3, 1) — ai-summary smooth reveal - prefers-reduced-motion 정책: 4개 미디어 쿼리 블록 + .js-observe transition fallback - 사용 예시: spot 마커, skeleton shimmer, ai-summary, .js-observe Closes #572 (part 3/7). Co-Authored-By: Claude Opus 4.7 (1M context) * docs(design-system): patterns.md add 5.4 dual palette usage * docs(design-system): tokens.md add magazine palette sub-table * docs(agent): rewrite design-system-llm.md as v2.2.0 1-pager SSOT * docs(agent): design-system-summary.md add v2.2.0 entry --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/agent/design-system-llm.md | 176 +++++++------ docs/agent/design-system-summary.md | 3 +- docs/design-system/README.md | 180 ++++++++----- docs/design-system/animations.md | 134 ++++++++++ docs/design-system/content-fundamentals.md | 155 +++++++++++ docs/design-system/patterns.md | 153 ++++++++--- docs/design-system/tokens.md | 283 +++++++++++---------- 7 files changed, 776 insertions(+), 308 deletions(-) create mode 100644 docs/design-system/animations.md create mode 100644 docs/design-system/content-fundamentals.md 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/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) => (
- {title} - {message} + + {title} + + + {message} + {action && (
; // 토큰 사용 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) => ( +``` + +- [ ] **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/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 +

+
+ +
+ ))} +
+ )} +
+ )} {plan && (
+
+ + + + + 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" + /> + +
+ )} + + {videoError && ( +
+ + {videoError} +
+ )} + {compositionError && ( +
+ + {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 && ( +
+

+ Final 9:16 Export +

+
+
+ + Download final + +
+ )} +

@@ -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} +

+ +
+ ) : 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/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/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; From 96489026021f3cbaada1dee839c8abae2d2278a8 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 28 May 2026 17:28:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(magazines):=20MAGAZINE=5FSTATUSES=20?= =?UTF-8?q?=E2=80=94=20'generating'=20=EB=88=84=EB=9D=BD=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20(#373=20drift)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB constraint post_magazines_status_check 는 'generating' 포함 5-state 인데 TS MAGAZINE_STATUSES 는 4-state. 1주일+ 잠재한 schema drift. #373 drift gate fix (#595) 가 처음 정상 작동하며 발견. 발생 경위: - 20260430120000: 'generating' 추가 (api-server generate handler 초기 INSERT) - 20260430150000: 4-state 로 축소 (TS 와 동기화) - 20260504133842: 'generating' 복원 (api-server insert + UI 필요) - 마지막 migration 의 TS 동기화 누락 → drift magazines.ts 의 MAGAZINE_STATUSES 에 'generating' 추가 (DB 와 같은 순서). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/lib/api/admin/magazines.ts | 1 + 1 file changed, 1 insertion(+) 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;