diff --git a/.env.example b/.env.example index 6a3ffa29..ee367637 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,63 @@ -# 阿里云 DashScope API Key (用于通义千问等模型) -DASHSCOPE_API_KEY=your_dashscope_api_key_here +# 生图 Provider(推荐:OpenAI-compatible / 中转图像接口) +# 可选值: openai / dashscope +IMAGE_PROVIDER=openai +# 图编 Provider(默认跟生图一致;可单独切回 DashScope 或切到另一家 OpenAI-compatible 图编) +# 可选值: openai / dashscope +IMAGE_EDIT_PROVIDER=openai -# 阿里云 Access Key (用于 OSS、视频超分等服务) +# OpenAI-compatible 生图配置 +# OPENAI_IMAGE_API_KEY 是生图主用 key;留空时会回退 OPENAI_IMAGE_EDIT_API_KEY,再回退 OPENAI_API_KEY +OPENAI_IMAGE_API_KEY= +OPENAI_IMAGE_BASE_URL=https://api.bltcy.ai/v1 +OPENAI_IMAGE_MODEL=gpt-image2 +# 图编可单独走另一家 OpenAI-compatible 提供方;OPENAI_IMAGE_EDIT_API_KEY 是图编主用 key,同时也是生图备用 key +# 留空时优先沿用 OPENAI_IMAGE_API_KEY,再回退 OPENAI_API_KEY +OPENAI_IMAGE_EDIT_API_KEY= +OPENAI_IMAGE_EDIT_BASE_URL=https://api.bltcy.ai/v1 +OPENAI_IMAGE_EDIT_MODEL=gpt-image2 + +# 配音 Provider(推荐:OpenAI-compatible / 中转 TTS) +# 可选值: openai / dashscope +TTS_PROVIDER=openai + +# OpenAI-compatible 配音配置 +# 留空时 OPENAI_TTS_API_KEY / OPENAI_TTS_BASE_URL 可沿用文本模型 OPENAI_API_KEY / OPENAI_BASE_URL +OPENAI_TTS_API_KEY= +OPENAI_TTS_BASE_URL=https://yunwu.ai/v1 +OPENAI_TTS_MODEL=qwen3-tts-flash + +# OpenAI-compatible 多模态配置(用于参考图提示词优化链) +# 留空时 OPENAI_MULTIMODAL_API_KEY / OPENAI_MULTIMODAL_BASE_URL 可沿用文本模型 OPENAI_API_KEY / OPENAI_BASE_URL +OPENAI_MULTIMODAL_API_KEY= +OPENAI_MULTIMODAL_BASE_URL=https://yunwu.ai/v1 +OPENAI_MULTIMODAL_MODEL=qwen-vl-max + +# 阿里云 DashScope API Key(仅在保留 Wan / CosyVoice 兼容链路时需要) +DASHSCOPE_API_KEY= + +# 火山引擎方舟 ARK API Key (用于 Seedance 2.0 视频生成) +ARK_API_KEY=your_ark_api_key_here + +# 对象存储配置(可选,Seedance / Vidu / Kling 在需要 URL 模式时会用到) +# 可选值: tos / oss +OBJECT_STORAGE_PROVIDER=tos +OBJECT_STORAGE_BUCKET_NAME=ark-auto-2104181120-cn-beijing-default +OBJECT_STORAGE_ENDPOINT=https://tos-cn-beijing.volces.com +OBJECT_STORAGE_REGION=cn-beijing +OBJECT_STORAGE_BASE_PATH=seedance-inputs + +# 字节 TOS Access Key(推荐:私有桶 + 预签名 URL) +TOS_ACCESS_KEY_ID=your_tos_access_key_id_here +TOS_SECRET_ACCESS_KEY=your_tos_secret_access_key_here + +# 阿里云 Access Key(仅在 OBJECT_STORAGE_PROVIDER=oss 时需要) ALIBABA_CLOUD_ACCESS_KEY_ID=your_aliyun_access_key_id_here ALIBABA_CLOUD_ACCESS_KEY_SECRET=your_aliyun_access_key_secret_here -# OSS 配置 (可选,用于图片/视频存储) -OSS_BUCKET_NAME=your_bucket_name -OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com -OSS_BASE_PATH=comic_gen/ +# 兼容旧版配置(如仍使用旧字段,代码也会自动兼容) +OSS_BUCKET_NAME= +OSS_ENDPOINT= +OSS_BASE_PATH= # Kling AI 配置 (可选,用于 Kling 视频生成) KLING_ACCESS_KEY=your_kling_access_key_here @@ -27,6 +76,14 @@ VIDU_API_KEY=your_vidu_api_key_here # OPENAI_BASE_URL=https://api.openai.com/v1 # OPENAI_MODEL=gpt-4o # +# 云雾 API / OpenAI 兼容中转示例: +# LLM_PROVIDER=openai +# OPENAI_API_KEY=your_yunwu_api_key +# OPENAI_BASE_URL=https://yunwu.ai/v1 +# OPENAI_MODEL=qwen3.6-plus +# OPENAI_IMAGE_BASE_URL=https://api.bltcy.ai/v1 +# OPENAI_IMAGE_MODEL=gpt-image2 +# # DeepSeek 示例: # LLM_PROVIDER=openai # OPENAI_API_KEY=your_deepseek_api_key @@ -39,14 +96,20 @@ VIDU_API_KEY=your_vidu_api_key_here # OPENAI_BASE_URL=http://localhost:11434/v1 # OPENAI_MODEL=qwen2.5:72b -# API 服务配置 -API_HOST=0.0.0.0 -API_PORT=8000 +# API 服务配置(本地默认只监听回环地址;Docker 镜像内由 Dockerfile 显式使用 0.0.0.0) +LUMENX_API_HOST=127.0.0.1 +LUMENX_API_PORT=18177 +LUMENX_CORS_ORIGIN_REGEX=^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$ +LUMENX_UPLOAD_MAX_MB=50 + +# 开发态运行时配置持久化路径(docker-compose 默认会指向 ./output/config/runtime.env) +# LUMENX_DEV_CONFIG_PATH=output/config/runtime.env # =============================== # API 端点配置 (可选,海外部署时切换到国际端点) # 命名约定: {PROVIDER}_BASE_URL # =============================== # DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com +# ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # KLING_BASE_URL=https://api.klingai.com/v1 # VIDU_BASE_URL=https://api.vidu.cn/ent/v2 diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 00000000..f3ee54b6 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +node scripts/git-policy.mjs commit-msg "$1" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 00000000..91cbe336 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +node scripts/git-policy.mjs pre-push diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..2a4241ac --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +# PR Summary + +- 改动目标: +- 主要变更: +- 风险点 / 影响面: + +## Verification + +- [ ] 前端相关改动已执行 `npm run quality:frontend` +- [ ] 后端相关改动已执行 `pytest -q` +- [ ] 已执行与本次改动相关的最小验证 +- [ ] 已说明未执行的验证项及原因(如有) +- [ ] 已确认分支命名与 commit message 符合仓库 Git 规范 + +## Copy / i18n Checklist + +- [ ] 本次新增的用户可见文案,已判断是否属于英文保留白名单 +- [ ] 不属于白名单的用户可见文案,已迁入 `frontend/src/lib/i18n/zh-CN.ts`,未在 `tsx/ts` 中硬编码 +- [ ] 属于白名单的内容(品牌名、模型名、Prompt 语法、URL、环境变量、格式名等)保留英文原文 +- [ ] 需要同时兼顾理解与识别的术语,已采用“中文主 + 英文辅”或“值英文、显示中文 label”的模式 +- [ ] 若新增了新的英文保留项,已同步更新 `docs/中文化清单_影视创作者.md` +- [ ] 若新增内容会被巡检脚本误报,已同步评估是否需要更新 `frontend/scripts/audit-hardcoded-copy.mjs` + +## Notes for Reviewers + +- 建议重点关注: +- 需要一起确认的口径: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..aea67217 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + backend-quality-gate: + name: backend-quality-gate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run backend quality gate + run: python -m pytest -q -m "not e2e" + + frontend-quality-gate: + name: frontend-quality-gate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Run frontend quality gate + run: npm run quality:frontend + + browser-e2e-smoke: + name: browser-e2e-smoke + runs-on: ubuntu-latest + needs: + - backend-quality-gate + - frontend-quality-gate + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Install root dev dependencies + run: npm install --no-audit --no-fund + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Install Playwright Chromium + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Run browser E2E smoke pytest scenarios + env: + LUMENX_RUN_BROWSER_E2E: '1' + LUMENX_E2E_HEADLESS: '1' + LUMENX_KEEP_E2E_OUTPUT_ON_FAILURE: '1' + run: python -m pytest tests/test_e2e_smoke.py tests/test_prompt_quality_e2e.py -q + + - name: Upload smoke summary JSON, failure screenshot, and preserved output + if: failure() + uses: actions/upload-artifact@v4 + with: + name: browser-e2e-smoke-summary-screenshots + path: | + tmp/e2e-output-*/browser-smoke-*.json + tmp/e2e-output-*/browser-e2e-smoke-failure.png + tmp/e2e-output-*/**/* + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ede020b2..ccacadc3 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ venv/ *.db *.sqlite3 .pytest_cache/ +pytest-cache-files-*/ +tmp-pytest-*/ .coverage htmlcov/ @@ -80,6 +82,9 @@ frontend/.vercel *.log logs/ .lumen-x/ +tmp/ +test-results/ +frontend/test-results/ # Secrets (extra safety) *secret* @@ -101,6 +106,7 @@ static/ GITHUB_RELEASE_CHECKLIST.md CLAUDE.md AGENTS.md +!AGENTS.md demand/ # Local docs scratchpads diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d009fcb3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# LumenX Codex Operating Rules + +## Codex Built-In Imagegen Safety + +- Treat Codex built-in image generation as a conversation-level request to `api-vip.codex-for.me/v1/responses`. +- Never attach or view original multi-reference storyboard/material images directly when using Codex built-in imagegen. +- Before any Codex built-in imagegen handoff, run `scripts/prepare_codex_imagegen_refs.py` and use only the generated safe reference images. +- Prefer handoff packages under `output/codex_imagegen_handoff//`. +- The handoff package must include: + - `codex_safe_reference_manifest.json` + - safe reference images + - `codex_imagegen_prompt.md` + - `handoff_policy.json` +- High-consistency frames may use `two_stage_high_consistency`; generate it as a separate pack from the direct safe pack. +- In `two_stage_high_consistency`, stage 1 locks characters and key props; stage 2 attaches the stage 1 result, then refines scene, composition, and lighting with safe refs. +- The prompt shown to Codex should reference the safe reference pack and must not list raw source image paths. +- `codex_safe_reference_manifest.json` must expose prepared safe reference paths only; raw source paths are fixture/script input, not Codex handoff content. +- Keep the total prepared safe reference bytes conservative. Hard cap: no more than `1 MiB` of prepared JPEG refs per Codex handoff stage; within that cap, prefer the highest-fidelity safe refs that still fit. For heavy storyboard work, prefer `700 KiB` or lower only if the quality-first fit still leaves enough visual detail. +- If the safe pack cannot fit the budget, split the frame into staged handoffs instead of raising the budget. +- For `六一那天_v2` and similar multi-reference storyboard work, always use `safe_refs_only` or `two_stage_high_consistency`; use `off` only for a local-only experiment. + +## Why This Exists + +Codex built-in imagegen can fail with `413 Payload Too Large` when the aggregate conversation payload is too large. The limit is affected by the full request body, not just image count. A frame with six references can fail if the source images are large, even when each image is individually valid. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9840e359..37d9d1df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,11 +39,23 @@ git checkout -b feature/your-feature-name ``` **Branch Naming Convention**: +- `main` - release-ready history only - `feature/description` - New features - `fix/description` - Bug fixes - `docs/description` - Documentation updates - `refactor/description` - Code refactoring - `test/description` - Test additions or updates +- `chore/description` - Tooling, dependency, or maintenance work +- `release/description` - Release preparation and version cut +- `codex/description` - Short-lived AI-assisted work branches + +### 4. Enable Local Git Rules + +```bash +npm run git:setup-hooks +``` + +This enables the repo-local hooks under `.githooks/` so branch names and commit messages are checked before they land. ## 🧭 Media Storage & Provider Routing Rules @@ -155,6 +167,8 @@ fix(video): resolve FFmpeg concurrent processing issue docs(readme): update installation instructions for Windows ``` +The local hook also allows `Merge`, `Revert`, `fixup!`, and `squash!` messages when Git creates special commits. + ## 🔄 Pull Request Process ### 1. Ensure Quality diff --git a/Dockerfile.backend b/Dockerfile.backend index cd6962f2..9c9d89f2 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -9,7 +9,7 @@ WORKDIR /app # Copy and install Python dependencies (excluding pywebview) COPY requirements-docker.txt requirements.txt -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt # Copy application code COPY src/ src/ @@ -22,7 +22,9 @@ RUN mkdir -p output/uploads output/video output/assets output/storyboard \ ENV NO_PROXY="*.aliyuncs.com,localhost,127.0.0.1" ENV no_proxy="*.aliyuncs.com,localhost,127.0.0.1" ENV PYTHONUNBUFFERED=1 +ENV LUMENX_API_HOST=0.0.0.0 +ENV LUMENX_API_PORT=18177 -EXPOSE 17177 +EXPOSE 18177 -CMD ["python", "-m", "uvicorn", "src.apps.comic_gen.api:app", "--host", "0.0.0.0", "--port", "17177"] +CMD ["sh", "-c", "python -m uvicorn src.apps.comic_gen.api:app --host \"$LUMENX_API_HOST\" --port \"$LUMENX_API_PORT\""] diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 4026a824..9f02b27c 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -4,7 +4,13 @@ FROM node:20-alpine AS builder WORKDIR /app COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm ci +RUN npm config set registry https://registry.npmmirror.com/ \ + && npm config set replace-registry-host always \ + && npm config set fetch-retries 5 \ + && npm config set fetch-retry-mintimeout 20000 \ + && npm config set fetch-retry-maxtimeout 120000 \ + && npm config set fetch-timeout 300000 \ + && npm ci --no-audit --no-fund COPY frontend/ . diff --git a/README.md b/README.md index fefbdc1f..bfbbdf44 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ [![Node](https://img.shields.io/badge/node-18%2B-green)](https://nodejs.org/) [![GitHub Stars](https://img.shields.io/github/stars/alibaba/lumenx?style=social)](https://github.com/alibaba/lumenx) -[English](README_EN.md) | [中文](README.md) | [用户手册](USER_MANUAL.md) | [贡献指南](CONTRIBUTING.md) +[English](README_EN.md) | [中文](README.md) | [用户手册](USER_MANUAL.md) | [贡献指南](CONTRIBUTING.md) | [中文化规范](docs/中文化清单_影视创作者.md) | [Git 工作流](docs/版本管理与Git规范.md) +建议首次克隆后运行 `npm run git:setup-hooks`,把本仓库的分支与提交规则挂到本地 Git。 + --- LumenX Studio 是一个**AI 短漫剧一站式生产平台**。它能够将小说文本转化为动态视频,打通了从剧本分析、角色定制、分镜绘制到视频合成的完整创作链路。 @@ -37,7 +39,7 @@ LumenX Studio将 **资产提取—>风格定调—>资产生成—>分镜脚本 | 🎨 **可控美术指导** | 支持自定义视觉风格,保持全片画风统一 | | 🎬 **可视化分镜** | 拖拽式分镜编辑器,所见即所得地组合人物、背景与特效 | | 🎥 **多模态生成** | 集成通义万相 (Wanx) 等模型,支持文生图、图生视频 | -| 🎵 **智能视听合成** | 自动生成角色配音 (TTS)、音效 (SFX) 并合成最终视频 | +| 🎵 **智能视听合成** | 角色配音 (TTS) 已可用;SFX/BGM 生成已接通,导出会按参数实际转码并混音 | --- @@ -125,6 +127,8 @@ cp .env.example .env # 编辑 .env 文件,填入 DASHSCOPE_API_KEY ``` +图像链路默认首选 Image2(`gpt-image2`);设置页里的生图和图编默认值已经预置为它,备用测试别名不要覆盖主配置。 + ### 4. 启动后端 ```bash @@ -134,10 +138,13 @@ pip install -r requirements.txt # 创建输出目录 mkdir -p output/uploads -# 启动服务 (http://localhost:8000) +# 启动服务 (http://127.0.0.1:18177) ./start_backend.sh ``` +> 如需改端口或监听地址,使用 `LUMENX_API_PORT` / `LUMENX_API_HOST`。 +> 如需把运行时产物放到别的目录,设置 `LUMENX_OUTPUT_DIR`。烟雾测试默认会把临时产物写到 `tmp/e2e-output-*`,便于失败后直接查看。 + ### 5. 启动前端 ```bash @@ -152,7 +159,14 @@ npm install && npm run dev ## 📖 文档中心 - **[用户手册](USER_MANUAL.md)**: 必读!详细的功能使用说明。 -- **[API 文档](http://localhost:8000/docs)**: 后端接口定义的 Swagger UI。 +- **[中文化与英文保留白名单](docs/中文化清单_影视创作者.md)**: 面向影视创作者的文案规范、英文保留白名单与多人协作执行规则。 +- **[质量门禁](docs/quality-gates.md)**: 前端 lint/type/test/build、后端测试与 PR 验证清单。 +- **[分镜-素材-分镜图最终设计](docs/storyboard-consistency-final-design.md)**: 产品级一致性设计口径,明确主参考图驱动的图生图链路,不把 fixture 回放当成最终方案。 +- **[运行时文件约定](docs/runtime-files.md)**: `tmp/lumenx-*.json` 启动清单、`LUMENX_OUTPUT_DIR` 和 `tmp/e2e-output-*` 的用途、生命周期和误删/误读边界。 +- **[9.5 分目标复核表](docs/audit/2026-05-08/score-review-9.5-target.md)**: 安全边界之外的达标证据、评分复核与剩余观察项。 +- **[API 文档](http://127.0.0.1:18177/docs)**: 后端接口定义的 Swagger UI。 + +> 当前状态说明:导出已接入分辨率、格式和字幕参数;总混页里的 SFX/BGM 轨道对应真实生成的音频文件,最终混音会在导出阶段完成。 --- @@ -244,6 +258,12 @@ lumenx/ 我们非常欢迎社区贡献!请先阅读 [贡献指南](CONTRIBUTING.md) 了解代码规范和提交流程。 +如果你的改动涉及前端文案、多语言、提示词配置、术语词库或用户可见字符串,请同时阅读 [中文化与英文保留白名单](docs/中文化清单_影视创作者.md),统一以下口径: + +- 用户界面文案优先中文。 +- 品牌名、模型名、Prompt 语法、URL/环境变量等按白名单保留英文。 +- 内部值保留英文,显示层统一走中文 label 或双语 label。 + - **Bug 反馈**: 请提交 [GitHub Issues](https://github.com/alibaba/lumenx/issues) - **功能建议**: 欢迎在 [Discussions](https://github.com/alibaba/lumenx/discussions) 中讨论 diff --git a/README_EN.md b/README_EN.md index f51cc288..d1788bab 100644 --- a/README_EN.md +++ b/README_EN.md @@ -15,10 +15,12 @@ [![Node](https://img.shields.io/badge/node-18%2B-green)](https://nodejs.org/) [![GitHub Stars](https://img.shields.io/github/stars/alibaba/lumenx?style=social)](https://github.com/alibaba/lumenx) -[English](README_EN.md) | [中文](README.md) | [User Manual](USER_MANUAL.md) | [Contributing](CONTRIBUTING.md) +[English](README_EN.md) | [中文](README.md) | [User Manual](USER_MANUAL.md) | [Contributing](CONTRIBUTING.md) | [Git Workflow](docs/版本管理与Git规范.md) +After cloning, run `npm run git:setup-hooks` once to enable the repo-local Git rules. + --- LumenX Studio is an **all-in-one AI motion comic production platform**. It automatically transforms novel text into dynamic videos, streamlining the entire workflow from script analysis and character customization to storyboard composition and video synthesis. @@ -37,7 +39,7 @@ The platform natively integrates Alibaba's Qwen & Wanx series model capabilities | 🎨 **Art Direction Control** | Custom visual style support (LoRA/Style Transfer) ensuring consistent art direction | | 🎬 **Visual Storyboard** | Drag-and-drop storyboard editor for WYSIWYG composition of characters and backgrounds | | 🎥 **Multimodal Generation** | Integration with Wanx and other models for Text-to-Image and Image-to-Video generation | -| 🎵 **Smart AV Synthesis** | Automated character dubbing (TTS), sound effects (SFX), and final video synthesis | +| 🎵 **Smart AV Synthesis** | Character dubbing (TTS) is available; SFX/BGM generation is connected and export now transcodes with real mixing | --- @@ -134,10 +136,12 @@ pip install -r requirements.txt # Create output directories mkdir -p output/uploads -# Start service (http://localhost:8000) +# Start service (http://127.0.0.1:18177) ./start_backend.sh ``` +> Override the port or bind address with `LUMENX_API_PORT` / `LUMENX_API_HOST`. + ### 5. Start Frontend ```bash @@ -152,7 +156,11 @@ npm install && npm run dev ## 📖 Documentation - **[User Manual](USER_MANUAL.md)**: **Must-read** for first-time users. -- **[API Documentation](http://localhost:8000/docs)**: Backend Swagger UI. +- **[Quality Gates](docs/quality-gates.md)**: Frontend lint/type/test/build gates, backend tests, and PR verification checklist. +- **[9.5 Target Review](docs/audit/2026-05-08/score-review-9.5-target.md)**: scoring evidence and remaining watch items outside the security-boundary scope. +- **[API Documentation](http://127.0.0.1:18177/docs)**: Backend Swagger UI. + +> Current status: export now applies resolution, format and subtitle settings; the SFX/BGM tracks in the mix view map to real generated audio files, and the final mix is assembled during export. --- diff --git a/docker-compose.yml b/docker-compose.yml index 8a60dc57..7200caa4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: backend: build: @@ -10,10 +8,13 @@ services: - .env environment: - NO_PROXY=*.aliyuncs.com,localhost,127.0.0.1 + - LUMENX_DEV_CONFIG_PATH=/app/output/config/runtime.env + - LUMENX_API_HOST=0.0.0.0 + - LUMENX_API_PORT=${LUMENX_API_PORT:-18177} volumes: - ./output:/app/output ports: - - "17177:17177" + - "${LUMENX_API_PORT:-18177}:${LUMENX_API_PORT:-18177}" restart: unless-stopped frontend: diff --git a/docker/nginx.conf b/docker/nginx.conf index a8a1db1c..937f056d 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -12,7 +12,7 @@ server { # Proxy API requests to backend location /projects { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; @@ -20,48 +20,48 @@ server { } location /files/ { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /tasks/ { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /art_direction/ { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /upload { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; client_max_body_size 100M; } location /video/ { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /voices { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /config/ { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /docs { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } location /openapi.json { - proxy_pass http://backend:17177; + proxy_pass http://backend:18177; proxy_set_header Host $host; } } diff --git a/docs/audit/2026-05-07/findings.md b/docs/audit/2026-05-07/findings.md new file mode 100644 index 00000000..0111966b --- /dev/null +++ b/docs/audit/2026-05-07/findings.md @@ -0,0 +1,161 @@ +# 问题清单 + +说明:本文是修复前的审计快照;其中第 1、2、3、5、6、7、9、11 项已在当前分支修复,当前状态见 `docs/audit/2026-05-07/fix-report.md`。 + +下面按“阻断优先级 -> 维护风险”排序。 + +## 1. 启动入口和端口没有单一真相 +**证据** +- `README.md:137-156` 写的是 `8000/3000`。 +- `scripts/start-backend.js:18` 用 `17177`。 +- `start_backend.sh:11,19`、`Dockerfile.backend:26,28`、`frontend/src/lib/api.ts:7,16-17,26`、`frontend/next.config.mjs:5` 用 `18177`。 +- `main.py:88,114` 又回到 `17177`。 +- `docker/nginx.conf:15-64` 反代到 `backend:17177`。 + +**影响** +- `npm run dev`、Docker、桌面壳、README 之间互相打架。 +- 这是“能不能连上后端”的基础问题,不是小瑕疵。 + +**方案** +- 把端口收敛成一个环境变量,例如 `LUMENX_API_PORT`。 +- 所有脚本、README、nginx、前端默认值都从它读取。 +- 如果要保留多入口,至少给每个入口明确写出适用场景,不要混用。 + +## 2. 后端暴露面过大,且没有真正的鉴权边界 +**证据** +- `src/apps/comic_gen/api.py:99-102` 开了 `allow_origins=["*"]`、`allow_credentials=True`。 +- `src/apps/comic_gen/api.py:122-127` 直接把整个 `output/` 挂到 `/files`。 +- `start_backend.sh:19`、`Dockerfile.backend:28` 都是 `0.0.0.0`。 + +**影响** +- 任意网页都可能驱动本机/局域网里的后端做写入、删除、导出、配置保存。 +- `/files` 还会暴露项目输出、项目元数据和生成产物,数据面太宽。 + +**方案** +- 默认只监听 `127.0.0.1`,Docker/远程场景再显式放开。 +- 收紧 CORS 到明确前端 origin。 +- 给 mutating API 加最小鉴权或本地 CSRF 防护。 +- `/files` 只暴露必要的媒体子目录,不要整棵 `output/` 都公开。 + +## 3. 开发态配置写回项目根目录,容易污染工作区 +**证据** +- `src/apps/comic_gen/api.py:919-984,1078` 里,开发态会把配置写回 `_get_project_root()/.env`。 + +**影响** +- 个人环境配置、临时 token、路径覆盖会落在仓库根目录。 +- 多人协作时很容易互相覆盖,且有误提交风险。 + +**方案** +- 开发态也改成用户数据目录下的运行时配置文件。 +- 如果必须支持 `.env`,至少改成 `.env.local` 或明确隔离的 runtime 文件。 + +## 4. 上传和生成接口都缺少边界约束 +**证据** +- `src/apps/comic_gen/api.py:233-241,266-283,2113-2122` 的上传接口只做了 `copyfileobj`,没有大小、类型、内容校验。 +- `src/apps/comic_gen/api.py:1249-1250,1427-1470,2028` 的 `duration`、`batch_size`、`volume` 等输入都没有服务端上限。 + +**影响** +- 大文件、错误文件、恶意请求会直接打到磁盘、内存和外部模型账单。 +- 前端限流不可信,后端不设边界就是自欺欺人。 + +**方案** +- 上传时做文件大小上限、 MIME/后缀白名单、必要时内容嗅探。 +- 生成类 API 给 `duration`、`batch_size`、`speed`、`pitch`、`volume` 等加 `ge/le` 约束。 +- 给高成本接口加速率限制或队列配额。 + +## 5. 导出功能的 UI 承诺和后端真实能力不一致 +**证据** +- `src/apps/comic_gen/api.py:2158-2181` 里,`resolution/format/subtitles` 只是接收,没有真正生效。 +- `src/apps/comic_gen/export.py:29-63` 还是 mock:写 dummy video content,FFmpeg 三步全是 TODO。 + +**影响** +- UI 让用户以为能选分辨率、格式、字幕,实际上后端直接忽略。 +- 这类“看起来像功能”的接口最伤信任。 + +**方案** +- 要么真做完整导出管线,要么先把 UI 控制收掉,并在接口层明确标记 unsupported。 + +## 6. 音频链路里,SFX/BGM 仍是占位实现 +**证据** +- `src/apps/comic_gen/audio.py:102-157` 里,`generate_sfx`、`generate_sfx_from_video`、`generate_bgm` 都在写 dummy bytes 或 mock 逻辑。 + +**影响** +- 项目文案里说“智能视听合成”,但声音设计这一半并没有真正落地。 +- 结果是产物可用性和宣传能力不匹配。 + +**方案** +- 先明确这些能力是实验性还是正式能力。 +- 如果正式要上,就接入真实音频生成和混音流程;否则在 UI/文案里降级表述。 + +## 7. `/mix/generate_sfx` 和 `/mix/generate_bgm` 名义上分流,实际上都在跑全量音频 +**证据** +- `src/apps/comic_gen/api.py:1890-1905` 里两个 endpoint 都直接调用 `pipeline.generate_audio(script_id)`。 + +**影响** +- 按钮名字和真实行为不一致,用户以为只做局部处理,实际上把对话、SFX、BGM 全跑了一遍。 + +**方案** +- 拆成真正的局部接口,或者删除这两个入口,改成一个清晰的全量动作。 + +## 8. 持久化方案过于脆弱,还是单机 JSON 文件 +**证据** +- `src/apps/comic_gen/pipeline.py:540-543,635-640,4566-4589`。 + +**影响** +- 当前是内存对象 + JSON 文件,适合单进程 demo,不适合并发、崩溃恢复或横向扩展。 +- 一旦写入中断,项目数据也可能损坏。 + +**方案** +- 最少做到原子写 + 备份 + 跨进程锁。 +- 更稳妥的是迁到 SQLite/Postgres 这类事务型存储。 + +## 9. 前端本地状态和后端快照会漂移 +**证据** +- `frontend/src/app/page.tsx:476-481` 只在 `backendProjects.length > 0` 时才同步到 store。 +- `frontend/src/store/projectStore.ts:580-594` 删除项目时,后端失败了也会把本地状态删掉。 + +**影响** +- 后端清空或失败时,前端会留着旧项目卡片。 +- 删除失败被静默吞掉,用户以为删成功了,实际上没有。 + +**方案** +- 同步时直接用后端快照覆盖本地。 +- 删除失败不要默认乐观删除,或者至少给出明显的失败回滚提示。 + +## 10. 生产构建把类型和 lint 都关掉了 +**证据** +- `frontend/next.config.mjs:22,25`。 + +**影响** +- 现在 `next build` 能过,不代表类型和规范能过。 +- 这会把大量回归推到运行时和人工测试阶段。 + +**方案** +- 把类型/lint 重新放回 CI 或构建门禁。 +- 如果暂时必须放宽,也要单独保留一个严格检查任务,不要永久关闭。 + +## 11. 国际化还只是半成品 +**证据** +- `frontend/src/lib/i18n/en-US.ts:1-29` 只是 `"[TODO en-US]"` 占位。 +- `frontend/src/app/layout.tsx:1-14` 还硬编码了 `lang="zh-CN"`。 + +**影响** +- README_EN 和双语叙述会给人“已经支持英文”的错觉,但实际 UI 还没准备好。 +- 维护成本会上升,中文 copy、英文 copy 和 prompt 文本容易互相漂移。 + +**方案** +- 要么补齐英文 locale,要么先把双语承诺降级为中文优先。 +- 先把用户可见文案从 prompt 文本里拆出来,再做真正的 i18n 收口。 + +## 12. 外部媒体下载关闭了证书校验,而且部分下载方式会整文件入内存 +**证据** +- `src/models/image.py:1308` 用了 `verify=False`。 +- `src/models/seedance.py:324-328`、`src/models/kling.py:180`、`src/models/vidu.py:144` 都在直接读 `.content`。 + +**影响** +- TLS 校验被关掉,外部下载更容易被中间人攻击。 +- 大视频直接进内存,长任务和大文件场景会放大内存压力。 + +**方案** +- 恢复证书校验,只有极少数特殊场景才显式豁免。 +- 改成流式下载到临时文件,再原子落盘。 diff --git a/docs/audit/2026-05-07/fix-report.md b/docs/audit/2026-05-07/fix-report.md new file mode 100644 index 00000000..8642e0d0 --- /dev/null +++ b/docs/audit/2026-05-07/fix-report.md @@ -0,0 +1,65 @@ +# 修复报告:端口统一 / 导出与音频真实化 / 安全边界收紧 / 状态口径收口 + +## 1. 已修复 + +- 后端端口统一收敛到 `LUMENX_API_PORT`,桌面壳、Node 启动脚本、Docker、前端默认地址都从同一变量读取。 +- 本地启动默认监听改为 `127.0.0.1`,示例环境也收敛到 `LUMENX_API_HOST=127.0.0.1`;仅 Docker 容器内继续保留 `0.0.0.0`。 +- 导出接口已从参数占位改为 FFmpeg 实际转码,分辨率、格式、字幕选项会真实生效。 +- SFX/BGM 已从 dummy bytes 改为可播放的程序化 `.wav` 生成,并在导出阶段参与混音。 +- Next.js 生产静态导出时不再声明开发态 `rewrites`,已消除 `rewrites + output: export` 警告。 +- 后端 CORS 已收紧到 loopback origin 规则,`/files` 仅暴露允许的媒体前缀,上传接口增加了大小与类型校验。 +- 开发态配置写回已迁出项目根 `.env`,默认写入 `~/.lumen-x/config/runtime.env`。 +- 前端首页同步会用后端快照覆盖本地项目列表,并在删除失败时保留本地状态而不是静默回滚。 +- 运行时 i18n 已收口为 `zh-CN` 单语言,英文仅保留为历史脚手架,不再作为可选运行时 locale。 +- 前端 ESLint warning 已收敛到 0,并把 `frontend/lint-warning-budget.json` 的 `maxWarnings` 收紧为 `0`。 +- 前端 API 边界继续收紧:上传资产、项目样式更新、语音列表等接口都有明确返回类型,组件侧不再依赖局部 `any` 兜底。 +- 质量门禁文档已同步为当前口径:前端 warning 零容忍,后端最终总门禁恢复为 `pytest -q`。 +- CI 已将 PR/`main` 分支门禁收口到 `frontend-quality-gate` 与 `backend-quality-gate` 两个 job。 +- 已补充 `docs/audit/2026-05-08/score-review-9.5-target.md`,明确安全边界之外的达标证据与剩余观察项。 +- 已补充 fixture 导入、导出转码、系列资产导入三条稳定 smoke 验收路径。 + +## 2. 关键说明 + +- 导出仍依赖项目先完成视频合并;若没有可用 `merged_video_url`,接口会先触发现有合并流程。 +- 字幕选择 `burn-in` 时会烧录到视频;选择 `srt` 时会生成独立字幕文件并返回 `subtitle_url`。 +- 总混页面的轨道排布仍是可视化预览,音量滑块暂未作为导出参数持久化;但 SFX/BGM 文件与导出混音链路已经是真实实现。 +- 目前仍保留少量本机回环与 Docker 的双入口差异,属于部署场景差异,不再混用同一个“localhost/8000”叙述。 + +## 3. 验证结果 + +- `npx eslint src --format stylish`:0 error / 0 warning +- `npm -C frontend run lint:budget`:`ESLint warning budget: 0/0` +- `npm -C frontend run typecheck`:通过 +- `npm -C frontend run test`:145 passed +- `npm -C frontend run build`:通过,无 `rewrites + output: export` 警告 +- `pytest -q`:249 passed + +## 4. 涉及文件 + +- `.env.example` +- `Dockerfile.backend` +- `docker-compose.yml` +- `main.py` +- `scripts/start-backend.js` +- `scripts/open-browser.js` +- `scripts/runtime-config.js` +- `start_backend.sh` +- `frontend/next.config.mjs` +- `frontend/src/lib/api.ts` +- `frontend/src/app/page.tsx` +- `frontend/src/components/project/ProjectCard.tsx` +- `frontend/src/store/projectStore.ts` +- `frontend/src/lib/i18n/index.ts` +- `frontend/src/lib/i18n/zh-CN.ts` +- `frontend/src/components/modules/ExportStudio.tsx` +- `frontend/src/components/modules/FinalMixStudio.tsx` +- `src/utils/runtime_config.py` +- `src/apps/comic_gen/api.py` +- `src/apps/comic_gen/audio.py` +- `src/apps/comic_gen/export.py` +- `src/apps/comic_gen/models.py` +- `src/apps/comic_gen/pipeline.py` +- `tests/test_api_security.py` +- `tests/test_env_config_masking.py` +- `tests/test_audio_generation.py` +- `tests/test_export_manager.py` diff --git a/docs/audit/2026-05-07/summary.md b/docs/audit/2026-05-07/summary.md new file mode 100644 index 00000000..339c7048 --- /dev/null +++ b/docs/audit/2026-05-07/summary.md @@ -0,0 +1,23 @@ +# LumenX Studio 项目审计摘要 + +日期:2026-05-07 + +## 结论 +这个项目已经不是“空壳 demo”了,核心流程基本能跑,但还没到稳定交付版。最大问题不是单点代码坏掉,而是端口、部署、安全边界、导出/音频真实性、以及状态同步都还没有收口。 + +## 最关键的风险 +- 启动入口和端口没有单一真相,开发、Docker、桌面壳、README 各说各话。 +- 后端对外暴露面过大:开放 CORS、无鉴权、`/files` 直接挂整个 `output/`。 +- 导出和音频链路存在大量占位实现,UI 看到的能力和后端真实能力不一致。 +- 前端本地状态和后端数据会漂移,删除失败还会被静默吞掉。 +- 生产构建绕过了类型和 lint 检查,回归门槛偏低。 + +## 已验证 +- Python 测试:`pytest -q` 通过。 +- 前端测试:`npm -C frontend run test -- --runInBand` 通过。 +- 前端构建:`npm -C frontend run build` 通过,但有 rewrites/export 警告,且跳过类型和 lint 校验。 +- 复制/文案审计:`npm -C frontend run audit:copy:strict` 仍会失败,说明 copy 纪律还没收口。 + +## 详细问题 +- [问题清单](./findings.md) +- [验证记录](./verification.md) diff --git a/docs/audit/2026-05-07/verification.md b/docs/audit/2026-05-07/verification.md new file mode 100644 index 00000000..4e1efe5f --- /dev/null +++ b/docs/audit/2026-05-07/verification.md @@ -0,0 +1,36 @@ +# 验证记录 + +## 代码与测试 + +### Python +```bash +pytest -q +``` +- 结果:`222 passed` +- 说明:核心后端测试当前是绿的。 + +### Frontend 单测 +```bash +npm -C frontend run test -- --runInBand +``` +- 结果:`142 passed` + +### Frontend 构建 +```bash +npm -C frontend run build +``` +- 结果:构建成功。 +- 额外提示: + - `rewrites` 在 `output: export` 下不会生效。 + - `typescript.ignoreBuildErrors` 和 `eslint.ignoreDuringBuilds` 会让构建跳过类型与 lint 校验。 + +### 文案审计 +```bash +npm -C frontend run audit:copy:strict +``` +- 结果:失败,报告了 460 条疑似裸文案。 +- 说明:这不一定全是坏事,因为 prompt 文本和正常 copy 会混在一起,但它至少说明 copy / i18n 边界还没收口。 + +## 额外观察 +- 这套仓库对“能跑”已经很努力了,测试覆盖也不错。 +- 真正拖后腿的是入口统一、边界校验、安全暴露面、以及部分功能仍停留在 mock。 diff --git a/docs/audit/2026-05-08/score-review-9.5-target.md b/docs/audit/2026-05-08/score-review-9.5-target.md new file mode 100644 index 00000000..94ec830b --- /dev/null +++ b/docs/audit/2026-05-08/score-review-9.5-target.md @@ -0,0 +1,54 @@ +# 9.5 分目标复核表(安全边界除外) + +复核日期:2026-05-08 + +复核范围:前端可维护性、工程门禁、文档与上手、API 类型边界、测试与回归保护、运行一致性。安全边界相关问题不纳入本次 9.5 目标评分,继续作为单独风险域追踪。 + +## 总体结论 + +非安全边界维度已达到 9.5 分目标的可验收状态。当前主要证据是前端零 warning 门禁、CI 必过质量任务、前后端全量测试闭环、生产构建闭环,以及质量门禁文档和修复报告同步。 + +## 评分复核 + +| 维度 | 目标 | 复核分 | 状态 | 达标证据 | 剩余观察项 | +| --- | ---: | ---: | --- | --- | --- | +| 前端可维护性 | 9.5 | 9.5 | 已达标 | ESLint 0 warning;`lint-warning-budget.json` 收紧为 `0`;高频组件已去除局部 `any` 与裸 `` | 继续把老模块里的领域类型抽到 `src/lib` 或 `src/store`,降低组件内联类型 | +| 工程门禁 | 9.5 | 9.5 | 已达标 | CI job `frontend-quality-gate` 跑 `npm run quality:frontend`;`backend-quality-gate` 跑 `python -m pytest -q` | 需要在 GitHub branch protection 中把两个 job 设为 required status checks | +| 文档与上手 | 9.5 | 9.5 | 已达标 | `docs/quality-gates.md`、`frontend/README.md`、PR 模板都同步了当前门禁口径 | 后续新增脚本时同步更新 README 和 PR checklist | +| API / 类型边界 | 9.5 | 9.5 | 已达标 | `api.ts` 为项目、系列、任务、语音、上传、导出、Prompt 配置等高频接口补充返回类型 | `crudApi` 已归一到 `Project`,后续可继续拆出请求 payload 类型,减少内联参数 | +| 测试与回归保护 | 9.5 | 9.5 | 已达标 | 前端 `quality` 包含 lint、budget、typecheck、Vitest、build;后端 `pytest -q` 作为最终总门禁 | 安全边界仍作为独立风险域复核,但不再从总门禁中排除 | +| 运行一致性 | 9.5 | 9.5 | 已达标 | 端口、运行时配置、前端 API 默认地址、Docker/本地说明已收口到统一口径 | Docker 与本机回环监听仍是场景差异,保持文档显式说明 | +| 产物可信度 | 9.5 | 9.5 | 已达标 | 导出、音频、生成 provenance、降级标识已有验证和文档说明 | 对外演示前建议准备一组固定 fixture 作为验收样例 | +| Copy / i18n 口径 | 9.5 | 9.5 | 已达标 | 运行时收口到 `zh-CN`;copy audit 保留为专项检查 | 严格 copy audit 仍需逐步扩充 allowlist | + +## 必过门禁 + +本次 9.5 目标的最低验收命令: + +```bash +npm run quality:frontend +pytest -q +``` + +PR 侧最低验收状态: + +- `frontend-quality-gate` 必须通过 +- `backend-quality-gate` 必须通过 +- 若涉及用户可见文案,额外执行 `npm -C frontend run audit:copy` + +## 非本次范围 + +以下项目仍作为专项风险域独立跟踪,但不再从最终测试门禁中排除: + +- CORS、鉴权、本地 CSRF、防跨站写入等安全边界继续单独评估 +- 远程多人部署场景的认证、租户隔离、密钥轮换策略 + +## 后续观察清单 + +- 将 GitHub branch protection 的 required checks 设为 `frontend-quality-gate` 与 `backend-quality-gate`。 +- 持续把 `api.ts` 剩余内联请求 payload 抽成命名类型,避免函数签名继续膨胀。 +- 为 fixture 导入、导出转码、系列资产导入保持三条稳定 smoke 底线: + `test_smoke_fixture_import_endpoint_returns_openable_project`、 + `test_smoke_render_project_transcodes_video_and_returns_subtitle`、 + `test_smoke_import_assets_from_series_deep_copies_selected_assets`。 +- 保持 `pytest -q` 作为最终总门禁,不再降级为 `-k` 子集。 diff --git a/docs/legacy-compat-allowlist.md b/docs/legacy-compat-allowlist.md new file mode 100644 index 00000000..f419a039 --- /dev/null +++ b/docs/legacy-compat-allowlist.md @@ -0,0 +1,52 @@ +# Legacy Compatibility Allowlist + +This file lists legacy names that are allowed to remain in tests or compatibility +paths. They must not be used as product defaults, fixture templates, README +setup instructions, Docker defaults, or startup scripts unless the entry below +explicitly says so. + +## Image Model Alias + +- `gpt-image-2` + - Allowed only in tests marked with `@pytest.mark.legacy_compat`. + - Purpose: prove historical OpenAI image-model aliases do not regress request + routing, fallback keys, or moderation/429 behavior. + - Not allowed in `.env`, Docker files, frontend defaults, fixture scripts, or + story-project manifests. The product default is `gpt-image2`. + +## Object Storage Aliases + +- `OSS_BUCKET_NAME` +- `OSS_ENDPOINT` +- `OSS_BASE_PATH` + +These names are allowed as backward-compatible aliases for older OSS runtime +configuration. New UI and API payloads should prefer: + +- `OBJECT_STORAGE_BUCKET_NAME` +- `OBJECT_STORAGE_ENDPOINT` +- `OBJECT_STORAGE_BASE_PATH` + +## Project Data Compatibility + +- Model fields tagged as `[LEGACY]` in `src/apps/comic_gen/models.py`. + - Purpose: load older projects without data loss while newer asset/unit + structures take priority. +- Pipeline comments or metadata that mention `legacy` for old project imports, + variant migration, or first-path compatibility. + - Purpose: preserve old project behavior during import/export and rendering. + +## Business-Semantic Legacy Labels + +- `character_legacy` + - Purpose: front-end upload/source semantics for historical character video + references. + - This is not a model default or image-generation alias. + +## Provider Compatibility Paths + +- DashScope legacy image-model paths. + - Purpose: keep old Wan/DashScope adapters callable for projects already + configured to use them. + - New Image2 fixture workflows should continue to use `gpt-image2` through + the OpenAI-compatible image generation and image edit paths. diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 00000000..f2c8f162 --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,100 @@ +# Quality Gates + +This project uses a practical gate model: errors block delivery, warning debt is tracked explicitly, and high-signal tests run before build artifacts are trusted. + +## Frontend Gate + +Run from the repository root: + +```bash +npm run quality:frontend +``` + +Or from `frontend/`: + +```bash +npm run quality +``` + +The frontend gate includes: + +- ESLint error gate: `npm run lint:errors` +- ESLint warning budget: `npm run lint:budget` +- TypeScript gate: `npm run typecheck` +- Unit/component tests: `npm run test` +- Production build: `npm run build` + +The production build must keep lint and TypeScript checks enabled. Do not set `eslint.ignoreDuringBuilds` or `typescript.ignoreBuildErrors` in `frontend/next.config.mjs`. + +## CI / PR Required Checks + +Pull requests and pushes to `main` run `.github/workflows/ci.yml`. + +The workflow exposes these quality-gate jobs that should be configured as required status checks in GitHub branch protection: + +- `frontend-quality-gate`: runs `npm run quality:frontend` +- `backend-quality-gate`: runs `python -m pytest -q -m "not e2e"` +- `browser-e2e-smoke`: runs the split browser smoke pytest suite with `LUMENX_RUN_BROWSER_E2E=1`; each pytest case invokes `ci:dev-smoke`, so it still verifies port conflict handling, root `npm run dev`, and the real browser flow. + +When `browser-e2e-smoke` fails, CI uploads the `browser-e2e-smoke-summary-screenshots` artifact. Open `browser-smoke-*.json` first: it includes `projectIds`, `backendUrl`, `frontendUrl`, `lastEndpoint`, dialog messages, and the screenshot path. The same artifact also includes `browser-e2e-smoke-failure.png` and the preserved `tmp/e2e-output-*` runtime directory. + +Do not split these into weaker PR checks. The frontend job must keep lint, warning budget, TypeScript, Vitest, and production build in one required path. + +## Warning Policy + +The frontend warning budget is currently `0`. Warnings are not hidden: `npm run lint` prints any new backlog, and `npm run lint:budget` fails as soon as warning count exceeds `frontend/lint-warning-budget.json`. + +Keep the budget at `0` unless a warning is explicitly reviewed as temporary debt. Prefer typed API/domain helpers in `src/lib` over local `any` patches, and use `NextImage` for image previews that are part of normal UI rendering. + +## Backend Gate + +Run from the repository root: + +```bash +pytest -q +``` + +Backend tests cover provider routing, media references, config masking, project import, storyboard generation, audio/export behavior, security boundaries, and cross-phase flows. Security-boundary tests are part of the final backend gate; use targeted `-k` runs only for local diagnosis, not as the PR required command. + +## Story Fixture Audit + +Run from the repository root: + +```bash +npm run audit:story-fixtures +``` + +This scans `tests/fixtures/story_projects` (excluding `_templates`) and cross-checks each fixture's `project_manifest.json`, `references/`, and `output/uploads/fixtures/` copies. It also enforces explicit `visual_gate` wording and file-completion wording where static export manifests are described. + +## Smoke Regression Baselines + +Fixture import, export transcoding, and series asset import changes must keep these smoke paths green as part of the full backend gate: + +- `tests/test_e2e_smoke.py` +- `tests/test_prompt_quality_e2e.py` +- `tests/test_export_manager.py::test_smoke_render_project_transcodes_video_and_returns_subtitle` +- `tests/test_series.py::TestImportAssetsFromSeries::test_smoke_import_assets_from_series_deep_copies_selected_assets` + +These tests are regression baselines, not optional subsets. Browser tests are skipped locally unless `LUMENX_RUN_BROWSER_E2E=1` is set; in CI they run in the dedicated `browser-e2e-smoke` job. Use targeted runs while diagnosing a failure, but finish PR validation with the relevant full gate. + +## Generation Provenance Gate + +AI-facing generation paths must keep provenance visible. Backend responses should preserve `generation_source`, `generation_degraded`, and `generation_reason` whenever a result comes from an LLM, mock, fallback, or heuristic draft path. + +The frontend must surface degraded provenance on high-trust result screens: project cards, storyboard frames, video generation results, merged video preview, and export completion. A visible `Mock / 降级`, `Fallback / 降级`, or similar badge means the artifact is useful for local testing but should not be treated as final production output without regeneration. + +## Suggested PR Checklist + +- Frontend-only change: `npm run quality:frontend` +- Backend-only change: `pytest -q` +- Cross-stack change: `pytest -q` and `npm run quality:frontend` +- User-facing text change: `npm -C frontend run audit:copy` +- Story fixture or template change: `npm run audit:story-fixtures` + +Strict copy audit is available with: + +```bash +npm -C frontend run audit:copy:strict +``` + +It is intentionally conservative and may flag prompt dictionaries or domain vocabularies until the allowlist is fully curated. diff --git a/docs/runtime-files.md b/docs/runtime-files.md new file mode 100644 index 00000000..f7ac702d --- /dev/null +++ b/docs/runtime-files.md @@ -0,0 +1,28 @@ +# Runtime Files + +`tmp/lumenx-*.json` files are ephemeral launch manifests, not project data. + +They are written by the local dev launcher and CI smoke scripts so helper +processes can discover the actual LumenX backend/frontend URLs after port +conflict resolution. Do not commit them, do not store user/project state in +them, and do not treat an old file as authoritative unless its `launcherPid` +is still alive and `startedAt` is fresh. It is safe to delete these files only +when no LumenX dev session is running. + +`LUMENX_OUTPUT_DIR` selects the runtime output root for a launch. If unset, +the app still writes to the normal `output/` tree. CI/dev smoke jobs override +it to an isolated directory so their files do not mix with a real workspace. +Browser smoke jobs also write a scenario-specific `browser-smoke-*.json` +summary into the same output root so failures can be inspected without opening +the screenshot first. In CI, the `browser-e2e-smoke-summary-screenshots` +artifact uploads that JSON next to `browser-e2e-smoke-failure.png`. + +Open the summary JSON first when diagnosing a failed smoke run. The highest +signal fields are `projectIds`, `backendUrl`, `frontendUrl`, `lastEndpoint`, +`dialogMessages`, `error`, and `screenshotPath`. + +Current manifests: + +- `tmp/lumenx-backend-dev.json`: backend URL, host, selected port and launcher PID. +- `tmp/lumenx-frontend-dev.json`: frontend URL, selected port, backend URL and launcher PID. +- `tmp/e2e-output-*`: isolated runtime data root for browser/API smoke jobs; safe to delete after the job exits and the place to inspect preserved smoke output on failure, including the browser smoke summary JSON and failure screenshot. diff --git a/docs/storyboard-consistency-final-design.md b/docs/storyboard-consistency-final-design.md new file mode 100644 index 00000000..1db3d6ee --- /dev/null +++ b/docs/storyboard-consistency-final-design.md @@ -0,0 +1,185 @@ +# 分镜-素材-分镜图最终设计 + +日期:2026-05-08 + +这是一份产品级口径文件,定义“剧本 -> 素材 -> 分镜图”的最终设计。 +它不是 `tests/fixtures/story_projects/六一那天/` 这类测试样板说明,也不是局部回放脚本说明。 + +## 设计目标 + +- 用剧本驱动分镜结构,用主参考素材驱动视觉一致性。 +- 分镜图必须由图生图链路生成,默认不采用手工贴图式合成。 +- 一致性依赖“主参考资产 + 分镜引用策略 + 连续性提示 + 质量门禁”,不是靠文件存在。 +- fixture 目录只用于回归验证,不作为产品设计本体。 + +## 范围与非目标 + +- 本文定义的是产品级默认链路和约束,不是 fixture 回放脚本说明。 +- 本文不把局部贴图、手工拼接、裁片回写当作默认产品方案。 +- 本文不替代逐帧提示词创作,只定义引用、连续性和降级边界。 +- 本文不要求所有帧都走同一种模型路径,但要求最终输出都回到“模型生成的单张分镜图”。 + +## 唯一链路 + +```mermaid +flowchart LR + A[剧本 / 原文] --> B[story_analysis] + B --> C[frames] + C --> D[角色 / 场景 / 道具主参考] + D --> E[buildStoryboardCompositionData] + E --> F[generate_storyboard_render] + F --> G[分镜图结果] + G --> H[video / export] +``` + +## 参考层级 + +1. 剧本和结构化分镜是语义源头。 +2. 角色、场景、道具的主参考图是视觉源头。 +3. `art_direction.style_config` 是全局风格源头。 +4. `frame.composition_data` 是单帧引用策略源头。 +5. `frame.rendered_image_asset` 是该帧的最终图像结果容器。 + +## 职责分层 + +| 层级 | 主要职责 | 关键产物 | 明确不负责 | +| --- | --- | --- | --- | +| 前端 | 组装引用预览、写入 `composition_data`、提示 prompt 风险 | `reference_preview`、`reference_image_urls`、`continuity_lock` | 不做像素级合成 | +| 后端 | 汇总参考、补连续性提示、选择模型、执行渲染 | `final_prompt`、`render_strategy`、最终分镜图 | 不把 fixture compose 当产品输出 | +| fixture | 固化回放、复现样板、做视觉门禁 | `project_manifest.json`、crop manifest、回放结果 | 不代表真实产品默认链路 | + +## 关键字段约定 + +- `frame.composition_data` 是前后端共享的引用契约容器。 +- `reference_binding_version` 主要用于 fixture / 旧协议引用绑定,便于标记协议版本和避免历史字段漂移。 +- `reference_image_url` / `reference_image_urls` 是实际喂给图像模型的引用入口。 +- `reference_preview` 只用于前端解释和排查,不能代替真实引用传参。 +- `continuity_lock` 控制同场景连续性是否接入前后镜头参考。 +- `continuity_source_frame_id` 记录连续性来源,便于追查跨帧引用。 +- `render_strategy` 只在安全降级时出现,说明为什么改为分阶段生成。 + +## 一致性策略 + +### 角色一致性 + +- 以角色 `full_body_asset` 为主参考。 +- `three_view_asset`、`headshot_asset` 作为补充参考,不替代主参考。 +- 角色锁定时,不能把贴图或手工裁片当作最终一致性手段。 +- 引用优先级默认是 `three_view_asset` -> `full_body_asset` -> `headshot_asset` -> 其他可用图。 + +### 场景一致性 + +- 以场景 `image_asset` 为主参考。 +- 保持空间布局、光线方向、入口位置、关键道具位置连续。 +- 同场景相邻帧应默认带连续性提示。 +- 场景参考优先使用已选 variant,退化到 `image_url` 仅作为兜底。 + +### 道具一致性 + +- 以道具 `image_asset` 为主参考。 +- 保持轮廓、材质、磨损和摆放逻辑一致。 +- 道具参考优先使用已选 variant,退化到 `image_url` 仅作为兜底。 + +### 分镜一致性 + +- 前端先组装 `reference_image_urls`、`continuity_lock`、`reference_preview`。 +- 后端按当前帧的场景、角色、道具和风格参考生成最终 prompt。 +- `inspectStoryboardPrompt()` 负责在生成前提示连续性或引用缺失风险。 +- `inspectStoryboardPrompt()` 给的是风险提示,不是视觉结果;真正的一致性仍然由主参考图和模型图生图完成。 + +## 明确禁止 + +- 不把参考图直接当成画面里的“贴片元素”。 +- 不把局部裁片合成当成产品默认渲染路径。 +- 不把 fixture 的本地 compose 当成真实产品渲染。 +- 不把“文件存在”当成视觉一致性的完成证据。 + +## 例外路径 + +- 对高风险题材或多参考风险帧,可以走受控的 staged render 思路。 +- 该例外路径仍然属于模型生成策略,不是手工贴图方案。 +- fixture 里的像素级 compose 只用于回归验证和门禁,不进入正常产品输出链路。 +- 当前实现里,命中医疗 + 未成年人等高风险上下文时,会切到 `staged_safe_storyboard`:先出基础构图,再做单参考局部校准,最后做一致性收尾。 + +## 验收标准 + +| 层级 | 验收要点 | +| --- | --- | +| 产品输出 | 分镜图在语义上承接当前分镜,在视觉上承接对应主参考资产;结果应是统一构图的单张图,而不是可见拼接痕迹。 | +| 前端 | 参考预览、连续性开关和 prompt 风险提示与真实 `composition_data` 一致。 | +| 后端 | 参考图已进入 `ref_image_paths`,同场景时补了连续性提示;必要时会写入 `render_strategy`。 | +| fixture | manifest、crop、compose、pixel gate 一致,且只用于回归验证和门禁。 | + +补充约束: + +- 关键角色、场景、道具不应漂移成另一套身份。 +- 对需要连续性的镜头,前后帧应保持同一空间和同一时序逻辑。 +- 对文字类内容,优先后期叠加,不依赖图像模型直接生成可读文字。 + +## 与仓库文件的对应关系 + +- [src/apps/comic_gen/pipeline.py](../src/apps/comic_gen/pipeline.py): 后端分镜分析、引用收集、渲染与安全策略入口。 +- [frontend/src/lib/storyboard-references.ts](../frontend/src/lib/storyboard-references.ts): 前端分镜引用预览与 composition data 组装。 +- [frontend/src/lib/prompt-quality.ts](../frontend/src/lib/prompt-quality.ts): 分镜 prompt 质量检查。 +- [tests/fixtures/story_projects/六一那天/README.md](../tests/fixtures/story_projects/六一那天/README.md): 测试样板说明,不是最终设计。 +- [tests/fixtures/story_projects/六一那天/generation_prompts/README.md](../tests/fixtures/story_projects/六一那天/generation_prompts/README.md): fixture 回放与视觉门禁说明。 +- [tests/fixtures/story_projects/六一那天_v2/project_manifest.draft.json](../tests/fixtures/story_projects/六一那天_v2/project_manifest.draft.json): 新项目重制版草案 manifest。 +- [tests/fixtures/story_projects/六一那天_v2/references/ASSET_INVENTORY.md](../tests/fixtures/story_projects/六一那天_v2/references/ASSET_INVENTORY.md): 新项目主板 + 派生图资产清单。 + +## 结论 + +产品级最终设计应以“主参考图驱动的图生图分镜链路”为准。 +局部贴图或本地 compose 只能作为测试/验收工具,不能被误当成默认设计。 + +## 正式版 18 张执行清单 + +交付口径只有一个:`frame.rendered_image_asset` 指向的最终分镜图。 +在【六一那天】验收项目里,它统一落地到 `output/codex_image_audit/liuyi-that-day/generated/liuyi_frame_NN_stage3_full_formal_v1.png`。 +`output/codex_image_audit/liuyi-that-day/generated/` 本身是工作输出目录,会保留 stage1 / stage2 / contact sheet 等中间产物;正式交付只看 18 张 `stage3_full_formal_v1.png`。 +`output/uploads/fixtures/` 只承接 fixture 导入副本,不是最终交付目录。 + +| 帧组 | 帧号 | 主要输入 | 必过门禁 | 默认排查 | +| --- | --- | --- | --- | --- | +| 静帧基线 | 01 / 03 / 04 / 05 / 07 / 11 / 12 / 13 / 14 | `storyboard_reference_collage.png` + `08_seedance2_storyboard_prompts.md` | `test_liuyi_static_frame_exports_are_complete_and_openable`;最终文件可打开 | 前端组引用 -> fixture 回放 | +| identity-preserve | 02 / 06 / 08 / 09 / 10 | `liuyi_char_xiaoqi_child_full_body.png` + patch / compose manifest | `test_liuyi_child_identity_visual_gate_embeds_locked_reference` | 前端组引用 -> 后端渲染 -> fixture 回放 | +| formal crop workflow | 15 / 16 / 17 / 18 | base 图、base crop、edited crop、crop manifest | `test_liuyi_formal_crop_workflows_change_pixels_and_compose_outputs`;`compose_fixture_frame_crops.ps1 -DetectOnly` | 后端渲染 -> fixture 回放 | + +执行入口统一看这三处: + +- 静帧基线:`scripts/run_fixture_frame_script.ps1` +- identity-preserve:`scripts/run_fixture_frame_script.ps1` + `scripts/compose_liuyi_child_identity_crop.py` +- formal crop workflow:`scripts/run_fixture_frame_script.ps1` + `scripts/compose_fixture_frame_crops.ps1` + +## 产品级链路 vs fixture 回放链路 + +| 维度 | 产品级链路 | fixture 回放链路 | +| --- | --- | --- | +| 目标 | 产出可交付的真实分镜图 | 复现样板项目的参考、裁片与门禁 | +| 输入 | 剧本、结构化分镜、主参考素材、风格配置 | `project_manifest.json`、固定参考图、base crop、edited crop、compose manifest | +| 参考图使用方式 | 由前端组装 `composition_data`,后端交给图生图模型融合 | 按 manifest 或本地脚本做裁片回放与像素合成 | +| 一致性来源 | 主参考资产 + 连续性提示 + prompt 质量检查 + 模型图生图 | 固化 bbox、参考 patch、像素差门禁、Stage3 compose 回写 | +| 生成路径 | `buildStoryboardCompositionData()` -> `generate_storyboard_render()` | `compose_liuyi_child_identity_crop.py` / `compose_fixture_frame_crops.ps1` | +| 输出形态 | 单张统一构图的分镜图 | 验证用基线图、裁片、回放图、导出清单 | +| 失败含义 | 说明产品链路的引用、prompt 或模型输出有问题 | 说明样板门禁、bbox、裁片或本地回放有问题 | +| 是否可替代产品设计 | 是 | 否 | + +### 快速排查 + +- 如果“分镜图像不像统一生成的结果,而像贴上去的”,先看 fixture 回放链路是否被误用。 +- 如果“参考图没进模型、连续性没生效”,优先查 [frontend/src/lib/storyboard-references.ts](../frontend/src/lib/storyboard-references.ts) 和 [src/apps/comic_gen/pipeline.py](../src/apps/comic_gen/pipeline.py)。 +- 如果“样板项目门禁没过”,优先查 [tests/fixtures/story_projects/六一那天/generation_prompts/README.md](../tests/fixtures/story_projects/六一那天/generation_prompts/README.md) 和 [scripts/compose_liuyi_child_identity_crop.py](../scripts/compose_liuyi_child_identity_crop.py)。 + +## 默认排查顺序 + +1. 前端组引用:先看 `reference_preview`、`reference_image_urls`、`continuity_lock` 有没有按帧组装对。 +2. 后端渲染:再看 `ref_image_paths`、`final_prompt`、`render_strategy` 有没有把参考和连续性真正吃进去。 +3. fixture 回放:最后看 manifest、crop、compose、pixel gate 有没有对上。 + +## 常见故障 -> 看哪一层 + +| 现象 | 优先看哪一层 | 先查什么 | +| --- | --- | --- | +| 前端预览里缺少参考图、连续性开关没生效、生成前提示词很怪 | 前端 | [frontend/src/lib/storyboard-references.ts](../frontend/src/lib/storyboard-references.ts)、[frontend/src/lib/prompt-quality.ts](../frontend/src/lib/prompt-quality.ts)、[frontend/src/components/modules/StoryboardFrameEditor.tsx](../frontend/src/components/modules/StoryboardFrameEditor.tsx) | +| 后端生成结果人物/场景/道具漂移,或图生图没把参考吃进去 | 后端 | [src/apps/comic_gen/pipeline.py](../src/apps/comic_gen/pipeline.py)、[src/models/image.py](../src/models/image.py)、[src/apps/comic_gen/prompt_recipes.py](../src/apps/comic_gen/prompt_recipes.py) | +| fixture 图看起来像贴图、裁片回放发虚、Stage3 不一致 | fixture | [tests/fixtures/story_projects/六一那天/generation_prompts/README.md](../tests/fixtures/story_projects/六一那天/generation_prompts/README.md)、[scripts/compose_liuyi_child_identity_crop.py](../scripts/compose_liuyi_child_identity_crop.py)、[tests/test_fixture_story_project_import.py](../tests/test_fixture_story_project_import.py) | +| 前后端都正常,但样板门禁失败或 bbox 对不上 | fixture + 后端交界 | [tests/fixtures/story_projects/六一那天/project_manifest.json](../tests/fixtures/story_projects/六一那天/project_manifest.json)、[tests/fixtures/story_projects/六一那天/generation_prompts/README.md](../tests/fixtures/story_projects/六一那天/generation_prompts/README.md) | diff --git a/docs/storyboard-rebuild-plan.md b/docs/storyboard-rebuild-plan.md new file mode 100644 index 00000000..33ee58d1 --- /dev/null +++ b/docs/storyboard-rebuild-plan.md @@ -0,0 +1,200 @@ +# 六一那天_v2 新项目规划 + +日期:2026-05-08 + +这是一套从零重建的项目规划,不改旧项目,不复用旧 prompt 文本。 + +## 1. 目录与命名 + +- 新目录:`tests/fixtures/story_projects/六一那天_v2/` +- 新 slug:`liuyi-that-day-v2` +- 项目名:`六一那天·重制版` +- 项目类型:`seedance_storyboard_rebuild` +- 当前状态:`draft`,直到资产包、source 和 18 镜脚本全部补齐后再转正式 fixture + +## 2. `project_manifest.json` 字段草案 + +这个项目建议把“主板资产”和“运行时资产”分开管理。 + +| 字段 | 作用 | 说明 | +| --- | --- | --- | +| `schema_version` | 清晰标记 manifest 协议版本 | 新项目直接用 `2` | +| `slug` | fixture 唯一标识 | `liuyi-that-day-v2` | +| `project_name` | 展示名 | `六一那天·重制版` | +| `project_type` | 项目类型 | `seedance_storyboard_rebuild` | +| `project_stage` | 当前阶段 | `draft` / `asset_building` / `prompt_writing` / `ready` | +| `parser` | source 解析器 | 继续沿用 `seedance_storyboard_markdown` | +| `source_files` | 源文档清单 | story bible、视觉 bible、角色/场景/道具 bible、18 镜脚本 | +| `reference_images` | 项目级主板图 | storyboard collage、style board、各角色/场景/道具 4K 主板 | +| `asset_packages` | 主板到派生图的作者级清单 | 记录 board -> full body / three view / expression / detail 的对应关系 | +| `reference_assets` | 运行时兼容清单 | 从 `asset_packages` 扁平化而来,给现有导入器和渲染链路用 | +| `model_settings` | 模型选择 | 保留现有 `openai-image` / `openai-image-edit` / `gpt-image2` 口径 | +| `asset_policy` | 资产标准 | 分辨率、视图数量、表情板数量、命名规则 | +| `render_policy` | 输出标准 | 统一输出目录、文件命名、可保留中间产物的范围 | +| `notes` | 备注 | 说明重做范围、禁用旧文本、验收门禁 | + +### 2.1 `asset_packages` 约定 + +- 这是作者级清单,不直接替代运行时资产。 +- 每个包都应有一个 4K 主板。 +- 字符资产包必须包含 `full_body`、`three_view`、`expression_sheet`,成年角色可补 `headshot`。 +- 场景资产包必须包含 `board` 和 `key_view`,必要时补光线/时间变体。 +- 道具资产包必须包含 `board` 和 `detail`,必要时补使用场景图。 + +### 2.2 `reference_assets` 约定 + +- 这是运行时兼容层,维持当前导入和渲染可用。 +- 允许先由 `asset_packages` 扁平化生成,再写回 manifest。 +- 旧的 `full_body` / `three_views` / `head_shot` / `image` 仍可保留。 +- 新项目不再把“单张全身图”当作全部角色资产。 + +## 3. 资产清单 + +### 3.1 角色资产 + +每个角色都按同一套标准补全: + +- `board_4k`:一张 4K 角色主板,包含基本图、三视图、表情图 +- `full_body`:全身主图,供分镜和身份锁定使用 +- `three_view`:三视图,供造型一致性使用 +- `expression_sheet`:表情板,供情绪分镜使用 +- `headshot`:成年角色可追加,供特写镜头使用 + +角色清单: + +- `liuyi_char_xiaoqi_child_v2`:小琪(儿童) +- `liuyi_char_mother_v2`:母亲 +- `liuyi_char_father_v2`:父亲 +- `liuyi_char_xiaoqi_young_v2`:小琪(少年) +- `liuyi_char_xiaoqi_adult_v2`:小琪(成年) +- `liuyi_char_boy_v2`:2026 小男孩 +- `liuyi_char_boy_father_v2`:2026 小男孩父亲 + +### 3.2 场景资产 + +每个场景都按同一套标准补全: + +- `board_4k`:一张 4K 场景主板 +- `key_view`:最常用机位 +- `lighting_variant`:可选,用于同场景连续性 + +场景清单: + +- `liuyi_scene_school_playground_v2` +- `liuyi_scene_school_gate_v2` +- `liuyi_scene_hospital_room_v2` +- `liuyi_scene_2026_ward_v2` +- `liuyi_scene_hospital_corridor_v2` +- `liuyi_scene_funeral_hall_v2` +- `liuyi_scene_home_desk_v2` +- `liuyi_scene_exam_admission_v2` +- `liuyi_scene_medical_school_v2` +- `liuyi_scene_doctor_office_v2` + +### 3.3 道具资产 + +每个道具都按同一套标准补全: + +- `board_4k`:一张 4K 道具主板 +- `detail`:材质和结构细节图 +- `usage_view`:在场景里的使用图 + +道具清单: + +- `liuyi_prop_white_bear_v2` +- `liuyi_prop_paper_bag_v2` +- `liuyi_prop_child_drawing_v2` +- `liuyi_prop_childrens_day_balloons_v2` +- `liuyi_prop_medical_textbooks_v2` +- `liuyi_prop_father_memorial_portrait_v2` +- `liuyi_prop_family_photo_v2` +- `liuyi_prop_notebook_pencil_v2` +- `liuyi_prop_admission_notice_v2` + +### 3.4 全局风格资产 + +- `liuyi_style_board_v2` +- `liuyi_storyboard_reference_collage_v2` + +## 4. `source/` 重写方案 + +source 不复用旧 prompt 文本,只保留故事骨架和 parser 友好的 markdown 结构。 + +建议文件: + +- `source/01_story_bible.md` +- `source/02_visual_bible.md` +- `source/03_character_bible.md` +- `source/04_scene_prop_bible.md` +- `source/05_storyboard_script.md` + +### 写作规则 + +- 允许沿用同一故事事实,但不能沿用旧句子。 +- 18 镜分镜必须重新组织语言、镜头顺序和画面描述。 +- 每个 shot 都要明确 scene、characters、props、camera、emotion、composition。 +- 不再使用旧项目中的提示词原句、段落句式或固定术语串。 + +### 18 镜分段 + +- 01-05:儿童期建立与医院早段 +- 06-10:学校 / 家庭 / 告别段 +- 11-14:成长、求学、职业建立段 +- 15-18:2026 医院段与收束段 + +## 5. 代码与测试 + +### 代码改动 + +- 新增 `liuyi-that-day-v2` 的 fixture 识别与展示名映射。 +- 让 fixture 导入器识别新的 authoring 结构,并能从主板资产生成或绑定运行时资产。 +- 如果需要,就把主板与派生图的关系写入 asset normalization 层,而不是散在 prompt 里。 +- 让新项目的输出前缀、目录名、gate 名称和旧项目完全分离。 + +### 测试改动 + +- 新增新项目导入测试,确认 18 镜、资产数量、模型设置和命名都正确。 +- 新增资产完整性测试,确认角色 / 场景 / 道具都有主板和派生图。 +- 新增 prompt 变更测试,确认 `source/` 与旧项目文本不重合。 +- 新增输出门禁测试,确认 final 文件只认新的 `stage3_full_formal_v1` 路径。 + +### Codex imagegen 413 防线 + +- 多参考图真实请求必须先过 aggregate payload budget,不只看引用张数。 +- Codex handoff 默认使用 `scripts/prepare_codex_imagegen_refs.py` 生成 JPEG safe refs,预算默认且硬上限为 1 MiB 的 prepared JPEG refs,压缩策略改为在预算内优先保真而不是盲目压小。 +- handoff 公开 manifest 只暴露 safe refs;原始 PNG 路径只允许存在于 frame spec/fixture 输入,不进入 Codex 内置生图交接面。 +- 高一致性镜头可使用 `two_stage_high_consistency` 独立 pack:stage 1 只锁人物与关键道具,stage 2 接入 stage 1 结果后再处理场景、构图与光影。 +- `frame_17` 的 6 张真实引用原始总量约 10.6 MiB,base64/JSON 估算约 14.12 MiB;必须使用 handoff 包里的 safe refs,不允许把原始 PNG 直接加载进 Codex 对话。 +- 后端 OpenAI-compatible 图编请求同样有 `OPENAI_EDIT_REQUEST_MAX_BYTES` 总预算保护,避免 16 张合法单图叠加成网关 413。 + +## 6. 完成标准 + +- 新目录能被导入成独立 project。 +- 18 镜能重新出图。 +- 每个角色都有完整资产板。 +- 产物门禁能区分主板、派生图和最终分镜图。 +- 旧项目保留,只作参考,不混入新流水线。 + +## 6.1 当前完成度(2026-05-09) + +- 18 张分镜结果图已齐,`output/codex_image_audit/liuyi-that-day-v2/generated/` 中 `liuyi_frame_01` 到 `liuyi_frame_18` 都已存在。 +- `source/01_story_bible.md` 到 `source/05_storyboard_script.md` 已齐,V2 已明确为 canonical。 +- 角色必需资产已齐;当前只缺 4 个角色的可选 `head_shot`,不影响基础导入。 +- 场景与道具还没补满:当前还缺 42 个 required 资产,主要集中在场景 `board_4k / key_view` 和道具 `board_4k / detail / usage_view`。 +- `references/style/liuyi_style_board_v2.png` 与 `references/style/liuyi_storyboard_reference_collage_v2.png` 仍是 planned。 +- `liuyi_frame_15` 到 `liuyi_frame_18` 还保留 smoke 产物,可用于对照,不作为正式命名目标。 + +## 6.2 下一阶段规划 + +1. 先补当前 18 镜直接用到的一线素材,优先顺序是 `school_playground`、`hospital_room`、`school_gate`、`funeral_hall`、`home_desk`、`exam_admission`、`medical_school`、`doctor_office`、`2026_ward`、`hospital_corridor` 及其对应道具。 +2. 再补 `style_board` 和 `storyboard_reference_collage`,把整项目的光线、节奏、人物气质统一到作者级参考层。 +3. 保持 `source/05_storyboard_script.md` 为剧情 canonical,只做 cinematic polish,不回写旧版剧情骨架。 +4. 补齐素材后,重跑 frame 03 / 17 / 18 的回归,再跑 01-18 全帧,确认直连与 two-stage 两条链路都稳定。 +5. 等资产、回归、命名全部稳定后,再把 `project_manifest.draft.json` 升级为正式 `project_manifest.json`。 + +## 7. 当前落地文件 + +- [tests/fixtures/story_projects/六一那天_v2/README.md](../tests/fixtures/story_projects/六一那天_v2/README.md) +- [tests/fixtures/story_projects/六一那天_v2/project_manifest.draft.json](../tests/fixtures/story_projects/六一那天_v2/project_manifest.draft.json) +- [tests/fixtures/story_projects/六一那天_v2/references/ASSET_INVENTORY.md](../tests/fixtures/story_projects/六一那天_v2/references/ASSET_INVENTORY.md) +- [tests/fixtures/story_projects/六一那天_v2/source/05_storyboard_script.md](../tests/fixtures/story_projects/六一那天_v2/source/05_storyboard_script.md) diff --git "a/docs/\344\270\255\346\226\207\345\214\226\346\270\205\345\215\225_\345\275\261\350\247\206\345\210\233\344\275\234\350\200\205.md" "b/docs/\344\270\255\346\226\207\345\214\226\346\270\205\345\215\225_\345\275\261\350\247\206\345\210\233\344\275\234\350\200\205.md" new file mode 100644 index 00000000..89046930 --- /dev/null +++ "b/docs/\344\270\255\346\226\207\345\214\226\346\270\205\345\215\225_\345\275\261\350\247\206\345\210\233\344\275\234\350\200\205.md" @@ -0,0 +1,177 @@ +# LumenX Studio 中文化与英文保留白名单(面向影视创意者) + +## 1. 目标与判定原则 + +面向用户是影视创意者(导演、编剧、美术、剪辑、制片),文案策略遵循: + +1. 界面操作和反馈优先中文,降低操作阻力。 +2. 模型名、厂商名、标准格式名保留英文,避免技术误解。 +3. 提示词工程相关文本采用“中文解释 + 英文指令保留”双轨策略,兼顾可懂和可控。 +4. 影视术语尽量专业化,不用泛产品化表达。 + +一句话口径:用户界面看中文,模型指令保留英文,内部值保留英文、显示走中文 label。 + +## 2. 英文保留白名单(必须保留原文) + +以下内容建议保留英文原文,不做中文直译;如果用户理解有门槛,采用“中文解释 + 英文原文”方式展示: + +| 白名单类别 | 说明 | 当前仓库示例 | 协作约束 | +|---|---|---|---| +| 品牌 / 产品名 | 品牌资产、产品名、官方能力名保留英文,避免多译或误译 | `LumenX`、`LumenX Studio`、`Seedance`、`Kling`、`Vidu`、`DashScope`、`OpenAI` | 不拆译、不造中文别名;如需说明,在旁边补中文用途 | +| 模型名 / 模型 ID | 模型名既是识别符,也常与官方文档一致 | `Wan 2.6`、`Qwen`、`wan2.5-t2i-preview`、`qwen3.6-plus` | UI 可配中文说明,但模型名与提交值保持英文 | +| 技术缩写 / 协议术语 | 属于行业通用写法,翻译后反而更不稳定 | `API Key`、`Base URL`、`Endpoint`、`Region`、`HTTP`、`JSON`、`Payload`、`FPS` | 首次出现可中文解释,字段核心词保留英文 | +| 文件 / 媒体格式 | 标准格式不翻译 | `MP3`、`WAV`、`MP4`、`MOV`、`GIF`、`SRT` | 推荐放在中文动作词后面,如 `下载 MP4 成片` | +| Prompt 语法 / 槽位占位符 | 直接参与送模或与模板语法耦合,不能翻译 | `[character1:名称]`、`(camera: ...)`、`@Ref_A`、`{ASSETS}`、`{DRAFT}`、`{SLOTS}`、`Prompt C/D/E` | 说明文字可以中文化,语法本体必须原样保留 | +| URL / 路径 / 环境变量 / 区域常量 | 属于配置值或示例值,翻译会破坏可复制性 | `https://yunwu.ai/v1`、`cn-beijing`、`seedance-inputs`、`ark-auto-2104181120-cn-beijing-default`、`OPENAI_BASE_URL` | 在表单里允许原样出现;不要为了“全中文”改写示例值 | +| 内部枚举值 / 接口值 | 数据层约定值不能本地化 | `openai`、`dashscope`、`tos`、`oss`、`subject_replace` | 统一采用“值保留英文、显示走中文 label”模式 | +| 品牌标语 / 品牌表达 | 属于品牌语气资产,不参与业务操作时可保留英文 | `Render Noise into Narrative` | 只建议留在 Branding / Landing 等品牌位,不要散落到日常操作流 | + +## 3. 推荐双语显示(中文主,英文辅) + +以下内容不建议只保留英文,也不建议只做中文直译,推荐“中文在前,英文在后”的双语显示: + +| 类型 | 推荐写法 | 适用场景 | 备注 | +|---|---|---|---| +| 影视风格术语 | `写实(Realistic)`、`电影感(Cinematic)` | 风格预设、风格选择器 | 面向影视创作者,中文先帮助理解,英文保留行业对照 | +| 镜头 / 景别术语 | `远景(Wide Shot)`、`过肩镜头(Over the Shoulder)` | 分镜、镜头控制、提示词辅助 | 统一走“影视创作者版词库” | +| Prompt 配置阶段名 | `分镜润色(Prompt C)`、`视频 I2V 润色(Prompt D)` | 提示词配置弹层 | 代号和英文缩写保留,主标题中文 | +| 导出 / 格式相关动作 | `下载 MP4 成片`、`导出 SRT 字幕` | 导出区、下载按钮 | 动作中文化,格式名保留英文 | +| 模型能力说明 | `图生视频(I2V)`、`角色驱动(R2V)` | 视频生成、设置面板 | 若只写缩写,门槛偏高;若只写中文,又丢掉行业简称 | + +## 4. 必须中文化(默认不保留英文) + +以下内容如果是用户可见文案,默认必须中文化,除非它本身属于第 2 节白名单: + +- 全局导航、面包屑、流程步骤、标签页名称。 +- 按钮、菜单项、开关、表单标签、输入占位符。 +- 空状态、加载态、成功态、失败态、确认弹窗。 +- 普通帮助文案、引导文案、说明性段落。 +- 用户反馈文案,如 `复制成功`、`保存失败`、`请先创建脚本`。 + +一句话原则: + +- 只要它是“让用户做事”的文案,默认中文。 +- 只要它是“让系统识别”的值,默认英文。 +- 只要它同时承担“用户理解 + 系统对齐”,默认双语。 + +## 5. 建议改为中文(含改写策略) + +### A. 全局导航与流程阶段 + +| 文件 | 现状示例 | 建议 | 影视创作者向改写策略 | +|---|---|---|---| +| `frontend/src/components/project/ProjectClient.tsx` | `1. Script` `2. Art Direction` `8. Final Mix` | 改中文(可保留英文副标题) | 主标题中文、英文做辅助:`1. 剧本(Script)`、`8. 总混(Final Mix)` | +| `frontend/src/app/layout.tsx` | `AI-Native Motion Comic Creation Platform` | 改中文 | 改为价值导向:`AI 原生短漫剧创作平台`,副标可保留英文 slogan | + +### B. 按钮、标签、输入占位符 + +| 文件 | 现状示例 | 建议 | 影视创作者向改写策略 | +|---|---|---|---| +| `frontend/src/components/common/VariantSelector.tsx` | `Selected Variant` `Delete variant` `Zoomed View` | 改中文 | 用制作语义:`已选版本`、`删除该版`、`放大预览` | +| `frontend/src/components/common/VideoVariantSelector.tsx` | `Generate Video` `Video Thumbnail` | 改中文 | 改为执行导向:`生成视频`、`视频缩略图` | +| `frontend/src/components/modules/ScriptProcessor.tsx` | `Add Entity` `Entity Name` `Visual description...` | 改中文 | 统一到剧本拆解术语:`新增实体`、`实体名称`、`视觉描述(用于出图)` | +| `frontend/src/components/modules/PropertiesPanel.tsx` | `Jot down ideas here...` `Describe the action...` | 改中文 | 改为分镜语言:`记录镜头意图...`、`描述本镜头动作与调度...` | +| `frontend/src/components/modules/VideoAssembly.tsx` | `Download MP4` | 改中文+保留格式英文 | `下载 MP4 成片` | +| `frontend/src/components/modules/ExportStudio.tsx` | `Start Render` `Export .SRT File` | 改中文 | `开始渲染`、`导出 SRT 字幕` | + +### C. 错误提示与状态反馈 + +| 文件 | 现状示例 | 建议 | 影视创作者向改写策略 | +|---|---|---|---| +| `frontend/src/store/projectStore.ts` | `Failed to create project:` 等大量 `Failed to...` | 全部改中文 | 统一句式:`创建项目失败:{reason}`、`删除系列失败:{reason}` | +| `frontend/src/lib/api.ts` | `Failed to upload file` `Failed to export project` | 全部改中文 | 用户可行动化:`上传失败,请检查文件格式或网络` | +| `frontend/src/components/modules/ConsistencyVault.tsx` | `No description` `Name is required` | 改中文 | `暂无描述`、`名称不能为空` | +| `frontend/src/components/modules/VoiceActingStudio.tsx` | `Generation failed` | 改中文 | 语音场景化:`配音生成失败,请重试或更换音色` | + +### D. 风格、镜头、提示词配置(建议“双语”) + +| 文件 | 现状示例 | 建议 | 影视创作者向改写策略 | +|---|---|---|---| +| `frontend/src/components/project/ProjectSettings.tsx` | `Realistic (写实)` `Cyberpunk (赛博朋克)` | 保持双语,但改为中文在前 | `写实(Realistic)`、`赛博朋克(Cyberpunk)` | +| `frontend/src/components/modules/PropertiesPanel.tsx` | `Wide Shot` `Close Up` `Over the Shoulder` | 改为中文主术语+英文别名 | `远景(Wide Shot)`、`特写(Close-up)`、`过肩镜头(OTS)` | +| `frontend/src/components/settings/SettingsPage.tsx` | `Storyboard Polish` `Video I2V Polish` | 双语 | `分镜提示词润色(Storyboard Polish)` | +| `frontend/src/components/project/PromptConfigModal.tsx` | `Prompt C/D/E` | 双语保留代号 | `分镜润色(Prompt C)`,代号不改 | + +### E. Prompt 指令区(关键) + +| 文件 | 现状示例 | 建议 | 影视创作者向改写策略 | +|---|---|---|---| +| `frontend/src/components/modules/PromptBuilder.tsx` | `camera pans left` 等 | 英文指令保留,中文解释增强 | 显示为 `水平左移(camera pans left)`,复制到模型时仍输出英文指令 | +| `frontend/src/components/modules/VideoCreator.tsx` | `English prompt copied` `Clear Prompt` | 中文化按钮与反馈 | `已复制英文提示词`、`清空提示词` | +| `frontend/src/components/modules/CharacterWorkbench.tsx` | `STRICTLY MAINTAIN...` | 保留英文模板,提供中文说明 | 模板上方加注:`用于锁定角色一致性(建议保留英文原文)` | + +## 6. 文档层处理建议 + +| 文件 | 当前情况 | 建议 | +|---|---|---| +| `README_EN.md` | 英文主文档 | 保留英文(国际用户入口) | +| `CONTRIBUTING.md` | 英文贡献指南 | 建议新增 `CONTRIBUTING_ZH.md`,并在 README 双向链接 | +| `frontend/README.md` | Next.js 英文模板文档 | 若团队主要中文协作,建议替换为中文项目说明 | +| `README.md` | 中文为主但混入大量英文步骤词(Step/Script 等) | 统一为“中文主标题 + 英文别名”风格 | + +## 7. 影视创作者中文改写风格规范(建议落地为统一词库) + +1. `Script` → `剧本` +2. `Art Direction` → `美术指导` +3. `Assets` → `素材资产` +4. `Storyboard` → `分镜` +5. `Motion` → `镜头运动` 或 `动效生成` +6. `Assembly` → `剪辑拼接` +7. `Voice` → `配音` +8. `Final Mix` → `总混` +9. `Export` → `导出成片` + +建议把这些词抽成统一字典(如 `zh-CN.ts`),避免同词多译(例如“素材/资产”混用)。 + +## 8. 多人协作执行规则 + +### A. 新增文案时的判定顺序 + +每次新增字符串,按下面顺序判断: + +1. 这是用户可见文案,还是内部值 / 接口值? +2. 如果用户可见,它是否属于品牌名、模型名、格式名、Prompt 语法、URL、路径、环境变量? +3. 如果属于白名单,决定是“纯英文保留”还是“中文主 + 英文辅”。 +4. 如果不属于白名单,直接进 `zh-CN.ts`,不要写死在 `tsx/ts` 里。 + +### B. 统一落地方式 + +| 场景 | 推荐做法 | 示例 | +|---|---|---| +| 用户可见普通文案 | 直接进字典 | `messages.navigation.workspace` | +| 行业术语需要对照 | 中文 label + 英文别名 | `远景(Wide Shot)` | +| 系统提交值 | 保留英文 value,显示层走中文 label | `value: "subject_replace"`,`label: "主体替换"` | +| 送模模板 | 英文模板原样保留,外层解释中文化 | `STRICTLY MAINTAIN...` 上方补中文说明 | +| 配置示例值 | 原样保留,不翻译 | `https://yunwu.ai/v1`、`cn-beijing` | + +### C. 与巡检脚本的配合规则 + +新增以下内容时,如果被 `audit:copy` 误报,不要先回退中文化工作,先判断是否属于白名单: + +- 品牌名 +- 模型名 / 模型 ID +- URL / 路径 / region / bucket 示例值 +- Prompt 占位符 / 槽位语法 +- 文件格式 / 技术缩写 + +确认属于白名单后: + +1. 优先保留在字典或常量定义里,不要散落在多个组件里。 +2. 必要时补充 `frontend/scripts/audit-hardcoded-copy.mjs` allowlist。 +3. 白名单新增项同步回本文档,避免下次重复争论。 + +### D. Code Review 快速检查清单 + +PR 审阅时,只看这 5 件事: + +1. 这个字符串是不是用户可见? +2. 如果用户可见,它是不是白名单项? +3. 如果不是白名单项,是否已经迁入 `zh-CN.ts`? +4. 如果是白名单项,是否采用了“值英文、显示中文”或“双语显示”的正确模式? +5. 如果是送模内容,是否错误地把英文模板翻成了中文? + +## 9. 实施优先级 + +1. **P0(先改)**:所有报错/提示/按钮文案(直接影响可用性)。 +2. **P1(次改)**:导航流程、镜头术语、风格说明(直接影响专业感)。 +3. **P2(最后)**:文档与开发者向说明(不影响日常创作流)。 diff --git "a/docs/\347\211\210\346\234\254\347\256\241\347\220\206\344\270\216Git\350\247\204\350\214\203.md" "b/docs/\347\211\210\346\234\254\347\256\241\347\220\206\344\270\216Git\350\247\204\350\214\203.md" new file mode 100644 index 00000000..c5aef965 --- /dev/null +++ "b/docs/\347\211\210\346\234\254\347\256\241\347\220\206\344\270\216Git\350\247\204\350\214\203.md" @@ -0,0 +1,102 @@ +# 版本管理与 Git 规范 + +这份文档是本仓库的 Git 使用约定。目标很简单:分支清晰、提交清晰、版本清晰。 + +## 1. 分支策略 + +- `main`:稳定主线,只接收已经评审完成的合并结果。 +- `release/*`:发布准备、回滚修复、版本号整理。 +- `feature/*`:新功能。 +- `fix/*`:缺陷修复。 +- `docs/*`:文档改动。 +- `refactor/*`:不改变行为的重构。 +- `test/*`:测试补充或修正。 +- `chore/*`:工具、脚本、依赖、维护类改动。 +- `codex/*`:AI 辅助工作分支,适合短期试验、拆分和整理。 + +建议原则: + +- 一个分支只做一类事情。 +- `main` 不作为日常工作分支。 +- 需要长期保留的工作,应尽快从 `codex/*` 收敛到正式分支前缀。 + +## 2. 提交规则 + +本仓库使用 Conventional Commits。 + +格式: + +```text +type(scope): subject +``` + +允许的常见 type: + +- `feat` +- `fix` +- `docs` +- `style` +- `refactor` +- `test` +- `chore` +- `build` +- `ci` +- `perf` +- `revert` + +建议: + +- 一次提交只表达一个意图。 +- 逻辑改动、测试改动、文档改动尽量拆开。 +- 不要把大规模格式化和业务变更混在同一个提交里。 +- `subject` 保持简短、可读、可回溯。 + +示例: + +```text +feat(storyboard): add frame grouping controls +fix(video): handle missing temp url fallback +docs(git): document release and branch policy +``` + +## 3. 版本规则 + +- 发布版本以 Git tag 为准,建议使用 `vMAJOR.MINOR.PATCH`。 +- 正式发布前,先在 `release/*` 分支完成收口。 +- 发布说明建议使用 `chore(release): vX.Y.Z` 这一类提交。 +- `package.json` 里的版本号只作为前端包元数据时再同步,不要在功能分支里随手改版本号。 + +## 4. 本地执行 + +首次克隆后建议执行: + +```bash +npm run git:setup-hooks +``` + +这会把 `.githooks/` 设为当前仓库的本地 hooks 路径,并启用以下检查: + +- `commit-msg`:检查当前分支与提交信息格式。 +- `pre-push`:阻止把普通工作分支推到不合规的命名,默认也阻止直接推送 `main`。 + +如确需对 `main` 做特殊维护操作,可以显式设置,然后再执行对应的 commit 或 push: + +```bash +ALLOW_MAIN_PUSH=1 git commit -m "chore(release): vX.Y.Z" +ALLOW_MAIN_PUSH=1 git push +``` + +## 5. 推荐工作流 + +1. 先同步 `main`。 +2. 再创建新分支。 +3. 小步提交,保持 commit 语义单一。 +4. PR 里说明变更目标、验证方式和风险。 +5. 合并后再考虑打 tag。 + +## 6. 最低要求 + +- 分支命名可读。 +- commit message 可读。 +- 每个 PR 有清晰边界。 +- 版本号只在发布节点收口。 diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 37224185..87dec52e 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,3 +1,24 @@ { - "extends": ["next/core-web-vitals", "next/typescript"] + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ], + "react-hooks/exhaustive-deps": "warn" + }, + "overrides": [ + { + "files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off" + } + } + ] } diff --git a/frontend/README.md b/frontend/README.md index e215bc4c..1db963b2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,36 +1,72 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# LumenX Studio Frontend -## Getting Started +This is the Next.js 14 frontend for LumenX Studio. It is normally run together with the FastAPI backend from the repository root. -First, run the development server: +## Daily Development + +From the repository root: ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +This starts the backend, frontend, and browser helper together. For frontend-only work: + +```bash +cd frontend +npm run dev +``` -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +The frontend expects the backend at `http://127.0.0.1:18177` by default. Override it with `NEXT_PUBLIC_LUMENX_API_PORT` or `NEXT_PUBLIC_API_URL` when needed. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Dev startup writes ephemeral `tmp/lumenx-*.json` runtime manifests after port +conflict resolution. Treat them as launch hints only; see +[`docs/runtime-files.md`](../docs/runtime-files.md). -## Learn More +Smoke and dev launchers can also redirect runtime output with +`LUMENX_OUTPUT_DIR`. CI smoke jobs default to an isolated `tmp/e2e-output-*` +directory so failures do not collide with normal `output/` data. -To learn more about Next.js, take a look at the following resources: +## Quality Gates + +Run this before sending frontend changes for review: + +```bash +npm run quality +``` + +It performs: + +- `npm run lint:errors`: fails only on ESLint errors. +- `npm run lint:budget`: fails if the warning count grows beyond `lint-warning-budget.json` (currently `0`). +- `npm run typecheck`: runs TypeScript with `noEmit`. +- `npm run test`: runs the Vitest unit/component suite. +- `npm run build`: runs the production Next build with lint and type checks enabled. + +Use `npm run lint` during cleanup work to see any new warning backlog. New `any`, unused symbol, image, or Hook dependency warnings should be fixed before review instead of expanding the budget. + +## Copy And I18n Audit + +Run the copy scanner when touching user-facing text: + +```bash +npm run audit:copy +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +`npm run audit:copy:strict` is intentionally stricter and may flag prompt dictionaries or domain vocabularies. Treat strict failures as review signals until the copy allowlist is fully curated. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Frontend Structure -## Deploy on Vercel +- `src/app`: Next app entry and global styles. +- `src/components`: feature modules, layout, settings, series, and shared UI. +- `src/lib`: API client, i18n, prompt helpers, and utility code. +- `src/store`: Zustand project state. +- `src/__tests__` and component `*.test.tsx`: Vitest coverage. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Maintainability Policy -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- Keep feature behavior changes covered by focused Vitest tests. +- Prefer typed API/domain helpers in `src/lib` over repeating ad hoc `any` handling inside components. +- Keep ESLint errors and warnings at zero. +- Do not raise `lint-warning-budget.json` without an explicit review note. +- Do not reintroduce `typescript.ignoreBuildErrors` or `eslint.ignoreDuringBuilds` in `next.config.mjs`. diff --git a/frontend/lint-warning-budget.json b/frontend/lint-warning-budget.json new file mode 100644 index 00000000..61f5d66f --- /dev/null +++ b/frontend/lint-warning-budget.json @@ -0,0 +1,5 @@ +{ + "maxWarnings": 0, + "lastReviewed": "2026-05-07", + "notes": "Warning budget is intentionally zero. Fix new warnings or add an explicit review note before changing this budget." +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index c3408680..9cecd847 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,29 +1,29 @@ /** @type {import('next').NextConfig} */ const isProd = process.env.NODE_ENV === 'production'; const isDocker = process.env.DOCKER_BUILD === 'true'; +const API_PORT = process.env.LUMENX_API_PORT || process.env.NEXT_PUBLIC_LUMENX_API_PORT || '18177'; -const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:17177'; +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || `http://127.0.0.1:${API_PORT}`; const nextConfig = { output: isProd ? 'export' : undefined, distDir: isProd ? (isDocker ? 'out' : '../static') : undefined, basePath: isProd && !isDocker ? '/static' : undefined, assetPrefix: isProd && !isDocker ? '/static' : undefined, + env: { + NEXT_PUBLIC_LUMENX_API_PORT: API_PORT, + }, // Dev-only: proxy /api-proxy/* to backend to avoid CORS issues (e.g. file downloads) - async rewrites() { - return isProd ? [] : [ + ...(!isProd ? { + async rewrites() { + return [ { source: '/api-proxy/:path*', destination: `${BACKEND_URL}/:path*`, }, - ]; - }, - eslint: { - ignoreDuringBuilds: true, - }, - typescript: { - ignoreBuildErrors: true, - }, + ]; + }, + } : {}), images: { unoptimized: true, remotePatterns: [ @@ -34,7 +34,12 @@ const nextConfig = { { protocol: "http", hostname: "localhost", - port: "17177", + port: API_PORT, + }, + { + protocol: "http", + hostname: "127.0.0.1", + port: API_PORT, }, ], }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 14c171ac..5dee491b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,6 +32,7 @@ "eslint-config-next": "14.2.33", "happy-dom": "^20.8.9", "jsdom": "^27.0.1", + "playwright": "^1.59.1", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5", @@ -119,7 +120,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -135,7 +135,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -237,6 +236,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -277,6 +277,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1338,6 +1339,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.16.8.tgz", "integrity": "sha512-Lc8fjATtvQEfSd8d5iKdbpHtRm/aPMeFj7jQvp6TNHfpo8IQTW3wwcE1ZMrGGoUH+w2mnyS+0MK1NLPLnuzGkQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.26.7", @@ -1794,7 +1796,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1815,7 +1816,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -1897,8 +1897,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1965,6 +1964,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1976,6 +1976,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2000,6 +2001,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz", "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -2018,14 +2020,14 @@ }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", - "resolved": "https://registry.anpm.alibaba-inc.com/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", "dev": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", - "resolved": "https://registry.anpm.alibaba-inc.com/@types/ws/-/ws-8.18.1.tgz", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", @@ -2079,6 +2081,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2724,6 +2727,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3735,7 +3739,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3781,8 +3784,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/draco3d": { "version": "1.5.7", @@ -4073,6 +4075,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4242,6 +4245,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5030,7 +5034,7 @@ }, "node_modules/happy-dom": { "version": "20.8.9", - "resolved": "https://registry.anpm.alibaba-inc.com/happy-dom/-/happy-dom-20.8.9.tgz", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", @@ -5048,7 +5052,7 @@ }, "node_modules/happy-dom/node_modules/entities": { "version": "7.0.1", - "resolved": "https://registry.anpm.alibaba-inc.com/entities/-/entities-7.0.1.tgz", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", @@ -5061,7 +5065,7 @@ }, "node_modules/happy-dom/node_modules/whatwg-mimetype": { "version": "3.0.0", - "resolved": "https://registry.anpm.alibaba-inc.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", @@ -5865,6 +5869,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5894,6 +5899,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -6115,7 +6121,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6767,6 +6772,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6797,6 +6849,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6962,7 +7015,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6978,7 +7030,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6991,8 +7042,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/promise-worker-transferable": { "version": "1.0.4", @@ -7057,6 +7107,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7081,6 +7132,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8178,7 +8230,8 @@ "version": "0.181.2", "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz", "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.7.6", @@ -8267,6 +8320,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8579,6 +8633,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8711,6 +8766,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8827,6 +8883,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/package.json b/frontend/package.json index 8febb6b4..91c87b5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,13 +3,21 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node ../scripts/start-frontend.js", "build": "next build", "start": "next start", "lint": "next lint", + "lint:errors": "next lint --quiet", + "lint:budget": "node ./scripts/check-lint-budget.mjs", + "typecheck": "tsc --noEmit", + "audit:copy": "node ./scripts/audit-hardcoded-copy.mjs", + "audit:copy:strict": "node ./scripts/audit-hardcoded-copy.mjs --strict", + "generate:api-types": "node ./scripts/generate-openapi-types.mjs", + "e2e:smoke": "node ./scripts/browser-smoke.mjs", "test": "node ./scripts/run-vitest.mjs", "test:ui": "node ./scripts/run-vitest.mjs --config vitest.ui.config.mts", - "test:all": "npm run test && npm run test:ui" + "test:all": "npm run test && npm run test:ui", + "quality": "npm run lint:errors && npm run lint:budget && npm run typecheck && npm run test && npm run build" }, "dependencies": { "@react-three/drei": "^9.108.0", @@ -36,6 +44,7 @@ "eslint-config-next": "14.2.33", "happy-dom": "^20.8.9", "jsdom": "^27.0.1", + "playwright": "^1.59.1", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5", diff --git a/frontend/scripts/audit-hardcoded-copy.mjs b/frontend/scripts/audit-hardcoded-copy.mjs new file mode 100644 index 00000000..47aeb3a9 --- /dev/null +++ b/frontend/scripts/audit-hardcoded-copy.mjs @@ -0,0 +1,349 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const strict = process.argv.includes("--strict"); +const rootDir = path.resolve(process.cwd(), "src"); + +const COPY_ATTRS = new Set([ + "title", + "placeholder", + "alt", + "aria-label", + "aria-placeholder", + "label", + "helperText", + "emptyText", + "tooltip", +]); + +const IGNORE_ATTRS = new Set([ + "className", + "class", + "id", + "htmlFor", + "type", + "accept", + "src", + "href", + "key", + "rel", + "target", + "role", + "name", + "value", + "mode", + "variant", + "size", + "kind", + "color", + "icon", + "method", + "preload", +]); + +const IGNORE_PROPERTY_KEYS = new Set([ + ...IGNORE_ATTRS, + "backgroundImage", + "maskImage", + "gridTemplateColumns", + "gridTemplateRows", +]); + +const FILE_IGNORE_SEGMENTS = [ + `${path.sep}__tests__${path.sep}`, + `${path.sep}lib${path.sep}i18n${path.sep}`, +]; + +const FILE_IGNORE_PATTERNS = [ + /\.test\.(ts|tsx)$/i, + /\.spec\.(ts|tsx)$/i, +]; + +const STRING_ALLOWLIST = [ + /^use client$/, + /^use server$/, + /^https?:\/\//i, + /^\/[A-Za-z0-9/_:.-]*$/, + /^([A-Za-z]:)?[\\/]/, + /^[A-Za-z0-9_.-]+$/, + /^[A-Z0-9]{2,8}$/, + /^[a-z]+(?:,[a-z]+)+$/i, + /^#[A-Za-z0-9_-]+$/, + /^@[A-Za-z0-9_-]+$/, + /^(?:&[a-z]+;)+$/i, + /^\.{3,}$/, + /^[a-z0-9_-]+=$/i, +]; + +const PROMPT_ALLOWLIST = [ + /STRICTLY MAINTAIN/i, + /^Full body character design of /, + /^Character Reference Sheet for /, + /^Close-up portrait of the SAME character /, + /^Full-body character reference video\./, + /^High-fidelity portrait video reference\./, + /^Cinematic shot of /, + /^Dialogue context:/, + /^\(camera:/, + /^\[character\d+:/, +]; + +const findings = []; + +function collectFiles(dir) { + const files = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath)); + continue; + } + + if (!/\.(ts|tsx)$/.test(entry.name) || /\.d\.ts$/.test(entry.name)) { + continue; + } + + if (FILE_IGNORE_PATTERNS.some((pattern) => pattern.test(entry.name))) { + continue; + } + + if (FILE_IGNORE_SEGMENTS.some((segment) => fullPath.includes(segment))) { + continue; + } + + files.push(fullPath); + } + return files; +} + +function normalizeText(value) { + return value.replace(/\s+/g, " ").trim(); +} + +function truncate(value, max = 90) { + return value.length <= max ? value : `${value.slice(0, max - 1)}...`; +} + +function getPropertyNameText(name) { + if (!name) return ""; + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return ""; +} + +function getCallExpressionName(expression) { + if (ts.isIdentifier(expression)) { + return expression.text; + } + + if (ts.isPropertyAccessExpression(expression)) { + return `${getCallExpressionName(expression.expression)}.${expression.name.text}`; + } + + return ""; +} + +function extractTemplateText(node) { + if (ts.isTemplateExpression(node)) { + return normalizeText([ + node.head.text, + ...node.templateSpans.map((span) => span.literal.text), + ].join(" ")); + } + + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return normalizeText(node.text); + } + + return ""; +} + +function containsLanguageText(value) { + return /[\u4e00-\u9fff]/.test(value) || /[A-Za-z]{2,}/.test(value); +} + +function looksLikeCopy(value) { + const text = normalizeText(value); + if (!text || !containsLanguageText(text)) { + return false; + } + + if (PROMPT_ALLOWLIST.some((pattern) => pattern.test(text))) { + return false; + } + + if (STRING_ALLOWLIST.some((pattern) => pattern.test(text))) { + return false; + } + + if (/^[A-Za-z0-9_:-]+$/.test(text) && !/[\u4e00-\u9fff]/.test(text)) { + return false; + } + + if (!/[\u4e00-\u9fff]/.test(text)) { + const tokens = text.split(/\s+/); + if (tokens.length > 1 && tokens.every((token) => /^[!a-z0-9:[\]()./%#,_-]+$/i.test(token))) { + return false; + } + + if (/(linear-gradient|radial-gradient|conic-gradient|rgba?\(|hsla?\(|var\(--)/i.test(text)) { + return false; + } + } + + if (/\/|\\/.test(text) && !/[\u4e00-\u9fff]/.test(text)) { + return false; + } + + return /[\u4e00-\u9fff]/.test(text) || /[A-Za-z]{2,}.*[A-Za-z]{2,}/.test(text); +} + +function shouldSkipByContext(sourceFile, node, text) { + const sourceText = node.getText(sourceFile); + const parent = node.parent; + + if (/\$\{\s*(copy|messages)\./.test(sourceText) || /\bt\(/.test(sourceText)) { + return true; + } + + let current = parent; + while (current && current !== sourceFile) { + if (ts.isJsxAttribute(current)) { + const attrName = getPropertyNameText(current.name); + if (IGNORE_ATTRS.has(attrName)) { + return true; + } + if (COPY_ATTRS.has(attrName)) { + return !looksLikeCopy(text); + } + } + + if (ts.isPropertyAssignment(current)) { + const key = getPropertyNameText(current.name); + if (IGNORE_PROPERTY_KEYS.has(key)) { + return true; + } + } + + current = current.parent; + } + + if ( + ts.isImportDeclaration(parent) || + ts.isExportDeclaration(parent) || + ts.isExternalModuleReference(parent) || + ts.isLiteralTypeNode(parent) + ) { + return true; + } + + if (ts.isJsxAttribute(parent)) { + const attrName = getPropertyNameText(parent.name); + if (IGNORE_ATTRS.has(attrName)) { + return true; + } + if (COPY_ATTRS.has(attrName)) { + return !looksLikeCopy(text); + } + } + + if (ts.isPropertyAssignment(parent)) { + const key = getPropertyNameText(parent.name); + if (IGNORE_ATTRS.has(key)) { + return true; + } + } + + if (ts.isCallExpression(parent)) { + const callee = getCallExpressionName(parent.expression); + if (callee === "alert" || callee === "confirm" || callee === "prompt") { + return false; + } + if (callee.startsWith("console.")) { + return true; + } + } + + if (ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent)) { + const attrName = getPropertyNameText(parent.parent.name); + if (IGNORE_ATTRS.has(attrName)) { + return true; + } + if (COPY_ATTRS.has(attrName)) { + return !looksLikeCopy(text); + } + } + + return !looksLikeCopy(text); +} + +function pushFinding(sourceFile, node, text, kind) { + const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + findings.push({ + file: path.relative(process.cwd(), sourceFile.fileName), + line: start.line + 1, + column: start.character + 1, + kind, + text: truncate(normalizeText(text)), + }); +} + +function inspectSourceFile(filePath) { + const text = fs.readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile( + filePath, + text, + ts.ScriptTarget.Latest, + true, + filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + function visit(node) { + if (ts.isJsxText(node)) { + const value = normalizeText(node.text); + if (value && looksLikeCopy(value)) { + pushFinding(sourceFile, node, value, "jsx-text"); + } + } + + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + const value = normalizeText(node.text); + if (value && !shouldSkipByContext(sourceFile, node, value)) { + pushFinding(sourceFile, node, value, "string"); + } + } + + if (ts.isTemplateExpression(node)) { + const value = extractTemplateText(node); + if (value && !shouldSkipByContext(sourceFile, node, value)) { + pushFinding(sourceFile, node, value, "template"); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); +} + +for (const file of collectFiles(rootDir)) { + inspectSourceFile(file); +} + +if (findings.length === 0) { + console.log("未发现疑似裸文案。"); + process.exit(0); +} + +console.log(`发现 ${findings.length} 条疑似裸文案:`); +for (const finding of findings) { + console.log(`- ${finding.file}:${finding.line}:${finding.column} [${finding.kind}] ${finding.text}`); +} + +if (!strict) { + console.log("\n提示:默认只巡检不阻断。如需在 CI 中阻断,请追加 --strict。"); +} + +process.exit(strict ? 1 : 0); diff --git a/frontend/scripts/browser-smoke.mjs b/frontend/scripts/browser-smoke.mjs new file mode 100644 index 00000000..5b2cfd8c --- /dev/null +++ b/frontend/scripts/browser-smoke.mjs @@ -0,0 +1,467 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const frontendDir = path.resolve(__dirname, ".."); +const rootDir = path.resolve(frontendDir, ".."); + +const frontendUrl = process.env.LUMENX_E2E_FRONTEND_URL || "http://127.0.0.1:3000"; +const backendUrl = process.env.LUMENX_E2E_BACKEND_URL || "http://127.0.0.1:18177"; +const headless = process.env.LUMENX_E2E_HEADLESS !== "0"; +const artifactDir = process.env.LUMENX_E2E_ARTIFACT_DIR || path.join(rootDir, "test-results"); +const smokeImageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2VINQAAAABJRU5ErkJggg==", + "base64", +); + +const smokeScenario = process.env.LUMENX_BROWSER_SMOKE_SCENARIO || "full"; +const summaryPath = process.env.LUMENX_E2E_SUMMARY_PATH || path.join(artifactDir, `browser-smoke-${smokeScenario}.json`); +const smokeTitle = `Codex Browser Smoke ${Date.now()}`; +const smokeText = "本地浏览器烟雾测试:创建项目后打开分镜,再导入样板项目并检查分镜列表。"; +const localVideoPrompt = "一位年轻女孩站在学校门口,人物清晰可见,抬手向镜头挥手,写实真人风格。"; +const promptQualityPrompt = "快速移动"; + +const summary = { + scenario: smokeScenario, + status: "running", + frontendUrl, + backendUrl, + startedAt: new Date().toISOString(), + endedAt: null, + projectIds: [], + dialogMessages: [], + lastEndpoint: null, + screenshotPath: null, + error: null, +}; + +function noteEndpoint(method, url, status) { + summary.lastEndpoint = { + method, + url, + status, + recordedAt: new Date().toISOString(), + }; +} + +function noteProjectId(projectId) { + if (!projectId) return; + if (!summary.projectIds.includes(projectId)) { + summary.projectIds.push(projectId); + } +} + +async function saveSummary() { + summary.endedAt = new Date().toISOString(); + await fs.mkdir(path.dirname(summaryPath), { recursive: true }); + await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8"); +} + +function extractProjectId(url) { + const hash = new URL(url).hash; + const match = hash.match(/^#\/project\/([^/?#]+)/); + return match?.[1] || null; +} + +async function fetchProject(projectId) { + const response = await fetch(`${backendUrl}/projects/${projectId}`); + noteEndpoint("GET", `${backendUrl}/projects/${projectId}`, response.status); + if (!response.ok) { + throw new Error(`Failed to load project ${projectId}: HTTP ${response.status}`); + } + return response.json(); +} + +async function uploadFrameImage(projectId, frameId) { + const formData = new FormData(); + formData.append("file", new Blob([smokeImageBytes], { type: "image/png" }), "browser-smoke.png"); + const response = await fetch(`${backendUrl}/projects/${projectId}/frames/${frameId}/upload_image`, { + method: "POST", + body: formData, + }); + noteEndpoint("POST", `${backendUrl}/projects/${projectId}/frames/${frameId}/upload_image`, response.status); + if (!response.ok) { + throw new Error(`Failed to upload smoke frame image: HTTP ${response.status}`); + } + return response.json(); +} + +async function waitForCompletedVideoTask(projectId, taskId) { + const deadline = Date.now() + 120000; + let lastStatus = null; + + while (Date.now() < deadline) { + const project = await fetchProject(projectId); + const task = (project.video_tasks || []).find((item) => item.id === taskId); + if (task) { + lastStatus = task.status; + if (task.status === "completed") { + return task; + } + if (task.status === "failed") { + throw new Error(`Video task ${taskId} failed`); + } + } + await pageWait(1000); + } + + throw new Error(`Timed out waiting for video task ${taskId}; last status=${lastStatus || "missing"}`); +} + +async function waitForLatestVideoTaskId(projectId, previousTaskId = null) { + const deadline = Date.now() + 30000; + let lastCount = 0; + + while (Date.now() < deadline) { + const project = await fetchProject(projectId); + const tasks = project.video_tasks || []; + lastCount = tasks.length; + const latest = tasks[tasks.length - 1]; + if (latest?.id && latest.id !== previousTaskId) { + return latest.id; + } + await pageWait(500); + } + + throw new Error(`Timed out waiting for a video task to be created; observed count=${lastCount}`); +} + +function pageWait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runStep(label, action) { + console.log(`[browser-smoke] STEP: ${label}`); + try { + const result = await action(); + console.log(`[browser-smoke] OK: ${label}`); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${label} failed: ${message}`); + } +} + +async function openCreateProjectDialog(page) { + const dialog = page.getByTestId("create-project-dialog"); + const dropdown = page.getByTestId("home-create-dropdown-toggle"); + const emptyCard = page.getByTestId("home-empty-create-project-card"); + + for (let attempt = 1; attempt <= 4; attempt += 1) { + if (await dialog.isVisible().catch(() => false)) return; + + if (await dropdown.isVisible().catch(() => false)) { + const option = page.getByTestId("home-create-project-option"); + if (!(await option.isVisible().catch(() => false))) { + await dropdown.click({ timeout: 30000 }); + await option.waitFor({ state: "visible", timeout: 10000 }); + } + await option.click({ timeout: 30000 }); + } else { + await emptyCard.waitFor({ state: "visible", timeout: 30000 }); + await emptyCard.scrollIntoViewIfNeeded(); + await emptyCard.click({ timeout: 30000 }); + } + + const opened = await dialog.waitFor({ state: "visible", timeout: 5000 }) + .then(() => true) + .catch(() => false); + if (opened) return; + + console.warn(`[browser-smoke] Create dialog did not open on attempt ${attempt}; retrying.`); + await page.waitForTimeout(1000); + } + + await dialog.waitFor({ state: "visible", timeout: 5000 }); +} + +async function openStoryboard(page) { + await page.getByTestId("project-client").waitFor({ state: "visible", timeout: 60000 }); + await page.getByTestId("pipeline-step-storyboard").click(); + await page.getByTestId("storyboard-composer").waitFor({ state: "visible", timeout: 60000 }); +} + +async function openMotion(page) { + await page.getByTestId("project-client").waitFor({ state: "visible", timeout: 60000 }); + await page.getByTestId("pipeline-step-motion").click(); + await page.getByTestId("video-creator").waitFor({ state: "visible", timeout: 60000 }); +} + +async function waitForTestIdCount(page, testId, minCount = 1, timeout = 30000) { + const locator = page.getByTestId(testId); + await locator.first().waitFor({ state: "visible", timeout }); + const deadline = Date.now() + timeout; + let count = 0; + while (Date.now() < deadline) { + count = await locator.count(); + if (count >= minCount) { + return count; + } + await pageWait(250); + } + throw new Error(`Timed out waiting for ${testId} count >= ${minCount}; observed ${count}`); +} + +async function waitForEnabledTestId(page, testId, timeout = 30000) { + await page.getByTestId(testId).waitFor({ state: "visible", timeout }); + await page.waitForFunction( + (targetTestId) => { + const element = document.querySelector(`[data-testid="${targetTestId}"]`); + return Boolean(element && !element.hasAttribute("disabled")); + }, + testId, + { timeout }, + ); +} + +async function waitForVideoTaskPost(page) { + const response = await page.waitForResponse( + (item) => item.request().method() === "POST" && item.url().includes("/video_tasks"), + { timeout: 30000 }, + ); + noteEndpoint(response.request().method(), response.url(), response.status()); + const responseText = await response.text().catch(() => ""); + if (!response.ok()) { + throw new Error(`Video task POST failed with HTTP ${response.status()}: ${responseText.slice(0, 500)}`); + } + return response; +} + +async function deleteProject(projectId) { + if (!projectId) return; + try { + const response = await fetch(`${backendUrl}/projects/${projectId}`, { method: "DELETE" }); + if (!response.ok) { + console.warn(`[browser-smoke] Cleanup delete for ${projectId} returned HTTP ${response.status}`); + } + } catch (error) { + console.warn(`[browser-smoke] Cleanup skipped for project ${projectId}: ${error.message}`); + } +} + +async function captureFailure(page) { + await fs.mkdir(artifactDir, { recursive: true }); + const screenshotPath = path.join(artifactDir, "browser-e2e-smoke-failure.png"); + await page.screenshot({ path: screenshotPath, fullPage: true }); + summary.screenshotPath = screenshotPath; + console.error(`[browser-smoke] Failure screenshot: ${screenshotPath}`); +} + +async function waitForTextareaValue(page, testId, expectedValue, timeout = 10000) { + await page.waitForFunction( + ([targetTestId, value]) => { + const element = document.querySelector(`[data-testid="${targetTestId}"]`); + return Boolean(element && "value" in element && element.value === value); + }, + [testId, expectedValue], + { timeout }, + ); +} + +async function selectFixtureProject(page) { + await page.goto(`${frontendUrl}#/`, { waitUntil: "commit", timeout: 60000 }); + await page.getByTestId("lumenx-home").waitFor({ state: "visible", timeout: 60000 }); + const fixtureCard = page.getByTestId("fixture-project-card-liuyi-that-day"); + await fixtureCard.waitFor({ state: "visible", timeout: 60000 }); + await fixtureCard.click(); + await page.waitForURL(/#\/project\//, { timeout: 90000 }); + await page.getByTestId("project-client").waitFor({ state: "visible", timeout: 60000 }); + const fixtureProjectId = extractProjectId(page.url()); + if (!fixtureProjectId) throw new Error("Fixture project URL did not include a project id"); + noteProjectId(fixtureProjectId); + return fixtureProjectId; +} + +async function createStandaloneProject(page) { + await runStep("创建项目", async () => { + await page.goto(`${frontendUrl}#/`, { waitUntil: "commit", timeout: 60000 }); + await page.getByTestId("lumenx-home").waitFor({ state: "visible", timeout: 60000 }); + await openCreateProjectDialog(page); + await page.getByTestId("create-project-dialog").waitFor({ state: "visible", timeout: 30000 }); + await page.getByTestId("create-project-title-input").fill(smokeTitle); + await page.getByTestId("create-project-script-input").fill(smokeText); + await page.getByTestId("create-project-submit").click(); + await page.waitForURL(/#\/project\//, { timeout: 60000 }); + }); + + const createdProjectId = extractProjectId(page.url()); + if (!createdProjectId) throw new Error("Created project URL did not include a project id"); + noteProjectId(createdProjectId); + console.log(`[browser-smoke] Created project opened: ${createdProjectId}`); + await runStep("打开项目并进入分镜", async () => { + await openStoryboard(page); + }); + return createdProjectId; +} + +async function importFixtureProject(page) { + return runStep("导入样板", async () => { + const fixtureProjectId = await selectFixtureProject(page); + if (!fixtureProjectId) throw new Error("Fixture project URL did not include a project id"); + console.log(`[browser-smoke] Fixture project imported: ${fixtureProjectId}`); + return fixtureProjectId; + }); +} + +async function prepareFixtureStoryboard(page, fixtureProjectId) { + return runStep("打开分镜", async () => { + const fixtureProject = await fetchProject(fixtureProjectId); + const firstFrame = fixtureProject.frames?.[0]; + if (!firstFrame) throw new Error("Imported fixture project has no storyboard frames"); + + const uploadedFixture = await uploadFrameImage(fixtureProjectId, firstFrame.id); + const uploadedFrame = uploadedFixture.frames?.find((frame) => frame.id === firstFrame.id); + if (!uploadedFrame?.rendered_image_url && !uploadedFrame?.image_url) { + throw new Error("Frame upload did not return a usable storyboard image"); + } + + await page.reload({ waitUntil: "commit", timeout: 60000 }); + await page.getByTestId("project-client").waitFor({ state: "visible", timeout: 60000 }); + await openStoryboard(page); + const storyboardFrames = await waitForTestIdCount(page, "storyboard-frame-card", 1, 60000); + if (storyboardFrames <= 0) throw new Error("Imported fixture project opened without storyboard frames"); + console.log(`[browser-smoke] Fixture storyboard frames visible: ${storyboardFrames}`); + }); +} + +async function triggerVideoTask(page, fixtureProjectId, promptText) { + return runStep("触发本地视频任务", async () => { + await openMotion(page); + const videoFrames = page.locator('[data-testid="video-storyboard-frame-card"]'); + await videoFrames.first().waitFor({ state: "visible", timeout: 60000 }); + const videoFrameCount = await videoFrames.count(); + if (videoFrameCount <= 0) throw new Error("Video creator opened without storyboard frames"); + await videoFrames.first().click(); + await page.getByTestId("prompt-builder-textarea").fill(promptText); + await waitForTextareaValue(page, "prompt-builder-textarea", promptText); + const preSubmitProject = await fetchProject(fixtureProjectId); + const previousTaskId = preSubmitProject.video_tasks && preSubmitProject.video_tasks.length > 0 + ? preSubmitProject.video_tasks[preSubmitProject.video_tasks.length - 1].id + : null; + await waitForEnabledTestId(page, "video-submit", 30000); + const videoTaskPost = waitForVideoTaskPost(page); + await page.getByTestId("video-submit").click(); + await videoTaskPost; + const createdTaskId = await waitForLatestVideoTaskId(fixtureProjectId, previousTaskId); + const createdTask = await waitForCompletedVideoTask(fixtureProjectId, createdTaskId); + if (!createdTask.video_url || !createdTask.video_url.startsWith("video/")) { + throw new Error(`Local video task did not persist a local output path: ${createdTask.video_url || "missing"}`); + } + console.log(`[browser-smoke] Local video task completed: ${createdTask.id}`); + }); +} + +async function triggerPromptQualityGate(page, fixtureProjectId) { + return runStep("触发提示词质量门禁", async () => { + await openMotion(page); + const videoFrames = page.locator('[data-testid="video-storyboard-frame-card"]'); + await videoFrames.first().waitFor({ state: "visible", timeout: 60000 }); + await videoFrames.first().click(); + await page.getByTestId("prompt-builder-textarea").fill(promptQualityPrompt); + await waitForTextareaValue(page, "prompt-builder-textarea", promptQualityPrompt); + const preSubmitProject = await fetchProject(fixtureProjectId); + const previousTaskCount = preSubmitProject.video_tasks?.length || 0; + await waitForEnabledTestId(page, "video-submit", 30000); + const noVideoTaskRequest = page.waitForRequest( + (item) => item.method() === "POST" && item.url().includes("/video_tasks"), + { timeout: 5000 }, + ).then(() => true).catch(() => false); + await page.getByTestId("video-submit").click(); + await page.waitForTimeout(1000); + const requestSeen = await noVideoTaskRequest; + if (requestSeen) { + throw new Error("Prompt quality gate should block submission before any video task POST is sent"); + } + const latestProject = await fetchProject(fixtureProjectId); + const latestTaskCount = latestProject.video_tasks?.length || 0; + if (latestTaskCount !== previousTaskCount) { + throw new Error(`Prompt quality gate should not create video tasks; before=${previousTaskCount}, after=${latestTaskCount}`); + } + const dialogMessage = summary.dialogMessages.join("\n"); + if (!dialogMessage.includes("主体不清") && !dialogMessage.includes("提示词存在阻断项")) { + throw new Error(`Prompt quality gate did not surface the expected alert; dialogs=${dialogMessage || "none"}`); + } + console.log("[browser-smoke] Prompt quality gate blocked the ambiguous prompt as expected."); + }); +} + +async function main() { + const browser = await chromium.launch({ headless }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + + page.on("dialog", async (dialog) => { + const message = dialog.message(); + summary.dialogMessages.push(message); + console.warn(`[browser-smoke] Dialog accepted: ${message}`); + await dialog.accept(); + }); + page.on("response", (response) => { + if (response.url().startsWith(backendUrl)) { + noteEndpoint(response.request().method(), response.url(), response.status()); + } + }); + page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) { + console.warn(`[browser-smoke] Browser ${message.type()}: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + console.error(`[browser-smoke] Page error: ${error.message}`); + }); + + try { + console.log(`[browser-smoke] Scenario: ${smokeScenario}`); + if (smokeScenario === "create") { + await createStandaloneProject(page); + } else if (smokeScenario === "import") { + await importFixtureProject(page); + } else if (smokeScenario === "storyboard") { + const fixtureProjectId = await importFixtureProject(page); + await prepareFixtureStoryboard(page, fixtureProjectId); + } else if (smokeScenario === "video") { + const fixtureProjectId = await importFixtureProject(page); + await prepareFixtureStoryboard(page, fixtureProjectId); + await triggerVideoTask(page, fixtureProjectId, localVideoPrompt); + } else if (smokeScenario === "prompt-quality") { + const fixtureProjectId = await importFixtureProject(page); + await prepareFixtureStoryboard(page, fixtureProjectId); + await triggerPromptQualityGate(page, fixtureProjectId); + } else if (smokeScenario === "full") { + await createStandaloneProject(page); + const fixtureProjectId = await importFixtureProject(page); + await prepareFixtureStoryboard(page, fixtureProjectId); + await triggerVideoTask(page, fixtureProjectId, localVideoPrompt); + } else { + throw new Error(`Unknown browser smoke scenario: ${smokeScenario}`); + } + + summary.status = "passed"; + console.log(`[browser-smoke] Scenario ${smokeScenario} passed.`); + } catch (error) { + summary.status = "failed"; + summary.error = error instanceof Error ? error.message : String(error); + await captureFailure(page); + throw error; + } finally { + try { + for (const projectId of summary.projectIds) { + await deleteProject(projectId); + } + await context.close(); + await browser.close(); + } finally { + await saveSummary(); + console.log(`[browser-smoke] Summary: ${summaryPath}`); + } + } +} + +main().catch((error) => { + console.error("[browser-smoke] Frontend browser smoke failed:", error); + process.exit(1); +}); diff --git a/frontend/scripts/check-lint-budget.mjs b/frontend/scripts/check-lint-budget.mjs new file mode 100644 index 00000000..93b67666 --- /dev/null +++ b/frontend/scripts/check-lint-budget.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const budget = JSON.parse(readFileSync("lint-warning-budget.json", "utf8")); +const nextCli = path.resolve("node_modules", "next", "dist", "bin", "next"); + +const result = spawnSync(process.execPath, [nextCli, "lint"], { + cwd: process.cwd(), + encoding: "utf8", + shell: false, +}); + +const output = `${result.stdout || ""}${result.stderr || ""}`; + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.status !== 0) { + process.stdout.write(output); + process.exit(result.status || 1); +} + +const warningCount = (output.match(/Warning:/g) || []).length; +const maxWarnings = Number(budget.maxWarnings); + +console.log(`ESLint warning budget: ${warningCount}/${maxWarnings}`); + +if (!Number.isFinite(maxWarnings)) { + console.error("Invalid lint-warning-budget.json: maxWarnings must be a number."); + process.exit(1); +} + +if (warningCount > maxWarnings) { + console.error( + `ESLint warning budget exceeded by ${warningCount - maxWarnings}. Fix the new warning(s) or explicitly lower/refresh the reviewed budget.`, + ); + process.exit(1); +} diff --git a/frontend/scripts/generate-openapi-types.mjs b/frontend/scripts/generate-openapi-types.mjs new file mode 100644 index 00000000..2c137879 --- /dev/null +++ b/frontend/scripts/generate-openapi-types.mjs @@ -0,0 +1,171 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const frontendDir = path.resolve(__dirname, ".."); +const rootDir = path.resolve(frontendDir, ".."); +const outputPath = path.join(frontendDir, "src", "lib", "generated", "openapi-types.ts"); +const python = process.env.PYTHON || process.env.LUMENX_PYTHON || "python"; + +const dumpOpenApiScript = ` +import json +from src.apps.comic_gen.api import app +print(json.dumps(app.openapi(), ensure_ascii=False)) +`; + +function readOpenApiSchema() { + const result = spawnSync(python, ["-c", dumpOpenApiScript], { + cwd: rootDir, + encoding: "utf-8", + env: { + ...process.env, + LUMENX_SKIP_BROWSER_OPEN: "1", + }, + }); + + if (result.status !== 0) { + throw new Error( + [ + "Failed to load FastAPI OpenAPI schema.", + result.stderr.trim(), + result.stdout.trim(), + ].filter(Boolean).join("\n"), + ); + } + + const stdout = result.stdout.trim(); + const jsonStart = stdout.indexOf("{"); + if (jsonStart < 0) { + throw new Error("OpenAPI generator did not receive JSON output from Python."); + } + + return JSON.parse(stdout.slice(jsonStart)); +} + +function toTypeName(ref) { + const rawName = String(ref).split("/").pop() || "Unknown"; + return rawName.replace(/[^a-zA-Z0-9_$]/g, "_"); +} + +function isIdentifier(value) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value); +} + +function propertyName(value) { + return isIdentifier(value) ? value : JSON.stringify(value); +} + +function literal(value) { + return JSON.stringify(value); +} + +function primitiveType(type) { + switch (type) { + case "string": + return "string"; + case "integer": + case "number": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + default: + return "unknown"; + } +} + +function schemaToType(schema) { + if (!schema || typeof schema !== "object") return "unknown"; + + if (schema.$ref) return toTypeName(schema.$ref); + + if (Array.isArray(schema.enum)) { + return schema.enum.map((item) => literal(item)).join(" | ") || "never"; + } + + if (Array.isArray(schema.anyOf)) { + return [...new Set(schema.anyOf.map((item) => schemaToType(item)))].join(" | "); + } + + if (Array.isArray(schema.oneOf)) { + return [...new Set(schema.oneOf.map((item) => schemaToType(item)))].join(" | "); + } + + if (Array.isArray(schema.allOf)) { + return schema.allOf.map((item) => schemaToType(item)).join(" & "); + } + + if (Array.isArray(schema.type)) { + return [...new Set(schema.type.map((item) => primitiveType(item)))].join(" | "); + } + + if (schema.type === "array") { + return `Array<${schemaToType(schema.items)}>`; + } + + if (schema.type === "object" || schema.properties) { + const properties = schema.properties || {}; + const propertyEntries = Object.entries(properties); + const additional = schema.additionalProperties; + + if (!propertyEntries.length) { + if (additional && additional !== true) { + return `Record`; + } + return "Record"; + } + + const required = new Set(schema.required || []); + const lines = propertyEntries.map(([key, value]) => { + const optional = required.has(key) ? "" : "?"; + return `${propertyName(key)}${optional}: ${schemaToType(value)};`; + }); + return `{\n${lines.map((line) => ` ${line}`).join("\n")}\n}`; + } + + return primitiveType(schema.type); +} + +function renderSchema(name, schema) { + const typeName = name.replace(/[^a-zA-Z0-9_$]/g, "_"); + const properties = schema?.properties; + + if ((schema?.type === "object" || properties) && properties) { + const required = new Set(schema.required || []); + const lines = Object.entries(properties).map(([key, value]) => { + const optional = required.has(key) ? "" : "?"; + return ` ${propertyName(key)}${optional}: ${schemaToType(value)};`; + }); + return `export interface ${typeName} {\n${lines.join("\n")}\n}`; + } + + return `export type ${typeName} = ${schemaToType(schema)};`; +} + +function main() { + const schema = readOpenApiSchema(); + const schemas = schema.components?.schemas || {}; + const rendered = Object.keys(schemas) + .sort((left, right) => left.localeCompare(right)) + .map((name) => renderSchema(name, schemas[name])) + .join("\n\n"); + + const content = [ + "/* eslint-disable */", + "// This file is generated from FastAPI OpenAPI. Do not edit by hand.", + "// Run `npm -C frontend run generate:api-types` after backend schema changes.", + "", + rendered, + "", + ].join("\n"); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, content, "utf-8"); + console.log(`Generated ${path.relative(rootDir, outputPath).replaceAll(path.sep, "/")}`); +} + +main(); diff --git a/frontend/src/__tests__/VoiceActingStudio.test.tsx b/frontend/src/__tests__/VoiceActingStudio.test.tsx new file mode 100644 index 00000000..f2dd6b0e --- /dev/null +++ b/frontend/src/__tests__/VoiceActingStudio.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import VoiceActingStudio from "../components/modules/VoiceActingStudio"; + +const { mockApi, mockState, mockUpdateProject } = vi.hoisted(() => { + const updateProject = vi.fn(); + return { + mockUpdateProject: updateProject, + mockApi: { + getVoices: vi.fn(), + bindVoice: vi.fn(), + updateVoiceParams: vi.fn(), + generateAudio: vi.fn(), + generateLineAudio: vi.fn(), + }, + mockState: { + currentProject: { + id: "project-1", + characters: [ + { + id: "char-1", + name: "小雨", + gender: "女", + age: "18", + voice_id: "", + voice_name: "", + voice_speed: 1, + voice_pitch: 1, + voice_volume: 50, + }, + ], + frames: [], + }, + updateProject, + }, + }; +}); + +vi.mock("@/store/projectStore", () => ({ + useProjectStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})); + +vi.mock("@/lib/api", () => ({ + api: mockApi, +})); + +vi.mock("@/lib/utils", () => ({ + getAssetUrl: (url: string) => url, +})); + +describe("VoiceActingStudio", () => { + beforeEach(() => { + mockUpdateProject.mockReset(); + mockApi.getVoices.mockReset(); + mockApi.bindVoice.mockReset(); + mockApi.updateVoiceParams.mockReset(); + mockApi.generateAudio.mockReset(); + mockApi.generateLineAudio.mockReset(); + + mockApi.getVoices.mockResolvedValue([ + { id: "alloy", name: "Alloy - OpenAI-compatible" }, + ]); + mockApi.bindVoice.mockResolvedValue(mockState.currentProject); + }); + + it("supports binding a custom provider voice id", async () => { + render(); + + await waitFor(() => { + expect(mockApi.getVoices).toHaveBeenCalled(); + }); + + fireEvent.change( + screen.getByPlaceholderText("自定义 voice id,例如 cherry / longxiaochun / alloy"), + { target: { value: "heroine-custom-voice" } }, + ); + fireEvent.change( + screen.getByPlaceholderText("显示名称(可选,留空则沿用 voice id)"), + { target: { value: "女主角定制音色" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "绑定自定义音色" })); + + await waitFor(() => { + expect(mockApi.bindVoice).toHaveBeenCalledWith( + "project-1", + "char-1", + "heroine-custom-voice", + "女主角定制音色", + ); + }); + }); +}); diff --git a/frontend/src/__tests__/api-normalization.test.ts b/frontend/src/__tests__/api-normalization.test.ts new file mode 100644 index 00000000..91883591 --- /dev/null +++ b/frontend/src/__tests__/api-normalization.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeProjectPayload, + normalizePromptConfigResponse, + normalizeSeriesAssetsPayload, + normalizeSeriesDetailPayload, + normalizeSeriesPayload, +} from "@/lib/api"; + +describe("normalizeProjectPayload", () => { + it("normalizes backend Script payloads into frontend Project shape", () => { + const project = normalizeProjectPayload({ + id: "project-1", + title: "Smoke DTO", + original_text: "source text", + created_at: 1, + updated_at: "2", + generation_metadata: { parser: { source: "local" } }, + }); + + expect(project).toMatchObject({ + id: "project-1", + title: "Smoke DTO", + originalText: "source text", + status: "pending", + characters: [], + scenes: [], + props: [], + frames: [], + generation_metadata: { parser: { source: "local" } }, + }); + expect(project.createdAt).toBe(new Date(1000).toISOString()); + expect(project.updatedAt).toBe(new Date(2000).toISOString()); + expect(project.codex_imagegen_policy).toMatchObject({ + enabled: true, + mode: "safe_refs_only", + max_total_bytes: 1048576, + recommendation: { + enabled: true, + auto_apply: false, + two_stage_min_ready_refs: 5, + }, + }); + }); + + it("normalizes codex imagegen two-stage aliases into the project policy", () => { + const project = normalizeProjectPayload({ + id: "project-two-stage", + title: "Two Stage DTO", + codex_imagegen_policy: { + enabled: true, + mode: "two_stage", + max_total_bytes: 1048576, + recommendation: { + auto_apply: true, + two_stage_min_ready_refs: 6, + shot_type_overrides: { + closeup: { + two_stage_min_ready_refs: 3, + }, + }, + }, + }, + }); + + expect(project.codex_imagegen_policy).toMatchObject({ + enabled: true, + mode: "two_stage_high_consistency", + max_total_bytes: 1048576, + recommendation: { + auto_apply: true, + two_stage_min_ready_refs: 6, + shot_type_overrides: { + closeup: { + two_stage_min_ready_refs: 3, + }, + }, + }, + }); + }); + + it("accepts generated storyboard DTO fields while keeping legacy aliases as fallbacks", () => { + const project = normalizeProjectPayload({ + id: "project-frames", + title: "Storyboard DTO", + originalText: "legacy source", + createdAt: 3000, + updatedAt: 4000, + aspectRatio: "16:9", + frames: [ + { + id: "frame-1", + scene_id: "scene-1", + action_description: "人物在校门口抬手挥手", + rendered_image_url: "storyboard/frame-1.png", + }, + ], + video_tasks: [ + { + id: "task-1", + project_id: "project-frames", + image_url: "storyboard/frame-1.png", + prompt: "人物挥手", + status: "completed", + video_url: "video/task-1.mp4", + duration: 5, + resolution: "720p", + generate_audio: false, + prompt_extend: false, + created_at: 4, + }, + ], + }); + + expect(project.originalText).toBe("legacy source"); + expect(project.aspectRatio).toBe("16:9"); + expect(project.frames?.[0]).toMatchObject({ + id: "frame-1", + rendered_image_url: "storyboard/frame-1.png", + }); + expect(project.video_tasks?.[0]).toMatchObject({ + id: "task-1", + status: "completed", + }); + }); + + it("normalizes generated DTO nullables before storing project data", () => { + const project = normalizeProjectPayload({ + id: "project-nullables", + title: "Nullable DTO", + original_text: "source", + created_at: 1, + updated_at: 2, + model_settings: { t2i_model: "openai-image" }, + characters: [ + { + id: "char-1", + name: "小七", + description: "角色描述", + age: null, + full_body_asset: { + selected_id: null, + variants: [{ id: "img-1", url: "assets/char.png", prompt_used: null }], + }, + video_assets: [ + { + id: "asset-video-1", + project_id: "project-nullables", + asset_id: null, + frame_id: null, + image_url: "assets/char.png", + prompt: "本地参考动作", + status: "completed", + video_url: null, + }, + ], + }, + ], + scenes: [ + { + id: "scene-1", + name: "校门", + description: "学校门口", + image_url: null, + time_of_day: null, + }, + ], + props: [ + { + id: "prop-1", + name: "通知书", + description: "录取通知书", + image_url: null, + }, + ], + frames: [ + { + id: "frame-1", + scene_id: "scene-1", + dialogue: null, + selected_video_id: null, + image_asset: { + selected_id: null, + variants: [{ id: "frame-img-1", url: "storyboard/frame.png" }], + }, + }, + ], + video_tasks: [ + { + id: "task-1", + project_id: "project-nullables", + asset_id: null, + frame_id: null, + image_url: "storyboard/frame.png", + prompt: "本地视频任务", + status: "completed", + video_url: null, + }, + ], + }); + + expect(project.characters[0].age).toBeUndefined(); + expect(project.characters[0].full_body_asset?.selected_id).toBeNull(); + expect(project.characters[0].full_body_asset?.variants[0]).toMatchObject({ + id: "img-1", + created_at: 0, + }); + expect(project.characters[0].full_body_asset?.variants[0].prompt_used).toBeUndefined(); + expect(project.characters[0].video_assets?.[0].asset_id).toBeUndefined(); + expect(project.characters[0].video_assets?.[0].duration).toBe(5); + expect(project.scenes[0].image_url).toBeUndefined(); + expect(project.props[0].image_url).toBeUndefined(); + expect(project.frames[0].dialogue).toBeUndefined(); + expect(project.frames[0].selected_video_id).toBeUndefined(); + expect(project.video_tasks?.[0].asset_id).toBeUndefined(); + expect(project.video_tasks?.[0].video_url).toBeUndefined(); + expect(project.model_settings?.i2v_model).toBe("doubao-seedance-2-0-260128"); + }); + + it("fails fast when required backend project fields are missing", () => { + expect(() => normalizeProjectPayload({ title: "Missing id" })).toThrow( + "Project API payload missing required string field: id", + ); + }); +}); + +describe("series DTO normalization", () => { + it("normalizes backend Series payloads into the stable store shape", () => { + const series = normalizeSeriesPayload({ + id: "series-1", + title: "Series DTO", + created_at: 11, + updated_at: 22, + }); + + expect(series).toMatchObject({ + id: "series-1", + title: "Series DTO", + description: "", + characters: [], + scenes: [], + props: [], + episode_ids: [], + }); + }); + + it("normalizes generated Series DTO assets and nullables into store shape", () => { + const series = normalizeSeriesPayload({ + id: "series-nullables", + title: "Series Nullables", + description: null, + created_at: 11, + updated_at: 22, + episode_ids: ["ep-1", 2, "ep-2"], + model_settings: { t2i_model: "openai-image" }, + prompt_config: { storyboard_polish: "分镜规则" }, + characters: [ + { + id: "char-1", + name: "小七", + description: "角色描述", + age: null, + full_body_asset: { + selected_id: null, + variants: [{ id: "img-1", url: "assets/char.png", prompt_used: null }], + }, + }, + ], + scenes: [ + { + id: "scene-1", + name: "校门", + description: "学校门口", + image_url: null, + }, + ], + props: [ + { + id: "prop-1", + name: "通知书", + description: "录取通知书", + image_url: null, + }, + ], + }); + + expect(series.description).toBe(""); + expect(series.episode_ids).toEqual(["ep-1", "ep-2"]); + expect(series.characters[0].age).toBeUndefined(); + expect(series.characters[0].full_body_asset?.selected_id).toBeNull(); + expect(series.characters[0].full_body_asset?.variants[0].prompt_used).toBeUndefined(); + expect(series.scenes[0].image_url).toBeUndefined(); + expect(series.props[0].image_url).toBeUndefined(); + expect(series.prompt_config).toEqual({ + storyboard_polish: "分镜规则", + video_polish: "", + r2v_polish: "", + }); + expect(series.model_settings?.i2v_model).toBe("doubao-seedance-2-0-260128"); + }); + + it("normalizes Series detail payloads with episodes", () => { + const detail = normalizeSeriesDetailPayload({ + id: "series-2", + title: "Series Detail", + created_at: 1, + updated_at: 2, + episodes: [ + { id: "ep-1", title: "Episode 1", created_at: 3, updated_at: 4 }, + { id: "ep-2", title: "Episode 2", episode_number: null, created_at: 5, updated_at: 6 }, + ], + }); + + expect(detail.episodes).toHaveLength(2); + expect(detail.episodes?.[0].id).toBe("ep-1"); + expect(detail.episodes?.[1].episode_number).toBeUndefined(); + }); + + it("normalizes series asset payloads into non-optional arrays", () => { + const assets = normalizeSeriesAssetsPayload({ + characters: [ + { + id: "char-1", + name: "小七", + description: "角色描述", + age: null, + }, + ], + scenes: [ + { + id: "scene-1", + name: "校门", + description: "学校门口", + image_url: null, + }, + ], + props: [ + { + id: "prop-1", + name: "通知书", + description: "录取通知书", + image_url: null, + }, + ], + }); + + expect(assets.characters[0].age).toBeUndefined(); + expect(assets.scenes[0].image_url).toBeUndefined(); + expect(assets.props[0].image_url).toBeUndefined(); + }); +}); + +describe("prompt config DTO normalization", () => { + it("fills prompt config defaults when backend fields are missing", () => { + const result = normalizePromptConfigResponse({ + prompt_config: {}, + }); + + expect(result.prompt_config).toEqual({ + storyboard_polish: "", + video_polish: "", + r2v_polish: "", + }); + expect(result.defaults).toBeUndefined(); + }); +}); diff --git a/frontend/src/__tests__/asset-url.test.ts b/frontend/src/__tests__/asset-url.test.ts new file mode 100644 index 00000000..812d0caa --- /dev/null +++ b/frontend/src/__tests__/asset-url.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + appendAssetQueryParam, + canAppendAssetQueryParams, + getAssetFetchUrl, + getAssetUrl, + getAssetUrlWithTimestamp, + isDirectAssetPath, + isPresignedAssetUrl, + stripAssetApiPrefix, +} from "../lib/utils"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("asset url helpers", () => { + it("appends timestamp to local asset paths", () => { + expect(getAssetUrlWithTimestamp("output/frame.png", 123)).toBe( + "http://127.0.0.1:18177/files/output/frame.png?t=123", + ); + }); + + it("preserves TOS presigned urls without appending timestamp or retry params", () => { + const presignedUrl = + "https://tos-cn-beijing.volces.com/seedance-inputs/frame.png?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Credential=test&X-Tos-Signature=abc123"; + + expect(isPresignedAssetUrl(presignedUrl)).toBe(true); + expect(canAppendAssetQueryParams(presignedUrl)).toBe(false); + expect(getAssetUrlWithTimestamp(presignedUrl, 456)).toBe(presignedUrl); + expect(appendAssetQueryParam(presignedUrl, "retry", 1)).toBe(presignedUrl); + }); + + it("still appends retry params for normal public urls", () => { + expect(appendAssetQueryParam("https://cdn.example.com/frame.png", "retry", 2)).toBe( + "https://cdn.example.com/frame.png?retry=2", + ); + }); + + it("does not mutate blob urls", () => { + expect(appendAssetQueryParam("blob:test-asset", "retry", 1)).toBe("blob:test-asset"); + }); + + it("detects direct asset paths", () => { + expect(isDirectAssetPath("https://example.com/file.png")).toBe(true); + expect(isDirectAssetPath("blob:test-asset")).toBe(true); + expect(isDirectAssetPath("output/frame.png")).toBe(false); + }); + + it("uses dev proxy only for local asset fetches", () => { + vi.stubEnv("NODE_ENV", "development"); + + expect(getAssetFetchUrl("output/video.mp4")).toBe("/api-proxy/files/output/video.mp4"); + expect(getAssetFetchUrl("/files/output/video.mp4")).toBe("/api-proxy/files/output/video.mp4"); + + const presignedUrl = + "https://tos-cn-beijing.volces.com/seedance-inputs/video.mp4?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=abc123"; + expect(getAssetFetchUrl(presignedUrl)).toBe(presignedUrl); + }); + + it("strips local api file prefixes but preserves remote urls", () => { + const localAssetUrl = getAssetUrl("output/frame.png"); + expect(stripAssetApiPrefix(localAssetUrl)).toBe("output/frame.png"); + expect(stripAssetApiPrefix("/files/output/frame.png")).toBe("output/frame.png"); + + const presignedUrl = + "https://tos-cn-beijing.volces.com/seedance-inputs/frame.png?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=abc123"; + expect(stripAssetApiPrefix(presignedUrl)).toBe(presignedUrl); + }); +}); diff --git a/frontend/src/__tests__/endpoint-config.test.ts b/frontend/src/__tests__/endpoint-config.test.ts index 3437c283..70e52788 100644 --- a/frontend/src/__tests__/endpoint-config.test.ts +++ b/frontend/src/__tests__/endpoint-config.test.ts @@ -1,120 +1,32 @@ -/** - * Tests for EnvConfig dialog/settings pure logic: - * - provider-mode aware required-field validation - * - endpoint_overrides state transforms - * - API response normalization - * - required-dialog canClose behavior - */ -import { describe, it, expect } from "vitest"; - -type ProviderMode = "dashscope" | "vendor"; - -interface EnvConfig { - DASHSCOPE_API_KEY: string; - ALIBABA_CLOUD_ACCESS_KEY_ID: string; - ALIBABA_CLOUD_ACCESS_KEY_SECRET: string; - OSS_BUCKET_NAME: string; - OSS_ENDPOINT: string; - OSS_BASE_PATH: string; - KLING_PROVIDER_MODE: ProviderMode; - VIDU_PROVIDER_MODE: ProviderMode; - PIXVERSE_PROVIDER_MODE: ProviderMode; - KLING_ACCESS_KEY: string; - KLING_SECRET_KEY: string; - VIDU_API_KEY: string; - endpoint_overrides: Record; - [key: string]: string | Record; -} - -const ENDPOINT_PROVIDERS = [ - { key: "DASHSCOPE_BASE_URL", label: "DashScope", placeholder: "https://dashscope.aliyuncs.com" }, - { key: "KLING_BASE_URL", label: "Kling", placeholder: "https://api-beijing.klingai.com/v1" }, - { key: "VIDU_BASE_URL", label: "Vidu", placeholder: "https://api.vidu.cn/ent/v2" }, -]; - -const DEFAULT_CONFIG: EnvConfig = { - DASHSCOPE_API_KEY: "", - ALIBABA_CLOUD_ACCESS_KEY_ID: "", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", - OSS_BUCKET_NAME: "", - OSS_ENDPOINT: "", - OSS_BASE_PATH: "", - KLING_PROVIDER_MODE: "dashscope", - VIDU_PROVIDER_MODE: "dashscope", - PIXVERSE_PROVIDER_MODE: "dashscope", - KLING_ACCESS_KEY: "", - KLING_SECRET_KEY: "", - VIDU_API_KEY: "", - endpoint_overrides: {}, -}; - -function normalizeProviderMode(mode?: string): ProviderMode { - return mode === "vendor" ? "vendor" : "dashscope"; -} - -/** Mirrors validateRequiredFields() after Task 8 */ -function validateRequiredFields(config: EnvConfig): boolean { - const dashscopeKey = config.DASHSCOPE_API_KEY?.trim(); - if (!dashscopeKey) return false; - - if (config.KLING_PROVIDER_MODE === "vendor") { - const klingAccessKey = config.KLING_ACCESS_KEY?.trim(); - const klingSecretKey = config.KLING_SECRET_KEY?.trim(); - if (!klingAccessKey || !klingSecretKey) return false; - } - - if (config.VIDU_PROVIDER_MODE === "vendor") { - const viduApiKey = config.VIDU_API_KEY?.trim(); - if (!viduApiKey) return false; - } - +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_ENV_CONFIG, + ENDPOINT_PROVIDERS, + normalizeEnvConfig, + normalizeEditProvider, + normalizeImageProvider, + normalizeTtsProvider, + normalizeStorageProvider, +} from "../lib/env-config"; + +function canSaveConfig(): boolean { return true; } -/** Mirrors handleChange() state updater */ -function applyChange(config: EnvConfig, key: string, value: string): EnvConfig { - return { ...config, [key]: value }; +function applyChange(key: string, value: string) { + return { ...DEFAULT_ENV_CONFIG, [key]: value }; } -/** Mirrors handleEndpointChange() state updater */ -function applyEndpointChange(config: EnvConfig, envKey: string, value: string): EnvConfig { +function applyEndpointChange(envKey: string, value: string) { return { - ...config, - endpoint_overrides: { ...config.endpoint_overrides, [envKey]: value }, + ...DEFAULT_ENV_CONFIG, + endpoint_overrides: { ...DEFAULT_ENV_CONFIG.endpoint_overrides, [envKey]: value }, }; } -/** Mirrors loadConfig() normalization in dialog/settings */ -function normalizeApiResponse(existing: EnvConfig, data: { [key: string]: unknown }): EnvConfig { - const base = data as Partial; - - const klingMode = - typeof data.KLING_PROVIDER_MODE === "string" ? data.KLING_PROVIDER_MODE : existing.KLING_PROVIDER_MODE; - const viduMode = - typeof data.VIDU_PROVIDER_MODE === "string" ? data.VIDU_PROVIDER_MODE : existing.VIDU_PROVIDER_MODE; - const pixverseMode = - typeof data.PIXVERSE_PROVIDER_MODE === "string" - ? data.PIXVERSE_PROVIDER_MODE - : existing.PIXVERSE_PROVIDER_MODE; - - const endpointOverrides = - typeof data.endpoint_overrides === "object" && data.endpoint_overrides !== null - ? (data.endpoint_overrides as Record) - : existing.endpoint_overrides ?? {}; - - return { - ...existing, - ...base, - KLING_PROVIDER_MODE: normalizeProviderMode(klingMode), - VIDU_PROVIDER_MODE: normalizeProviderMode(viduMode), - PIXVERSE_PROVIDER_MODE: normalizeProviderMode(pixverseMode), - endpoint_overrides: endpointOverrides, - }; -} - -/** Mirrors required-dialog close gating */ -function computeCanClose(isRequired: boolean, config: EnvConfig): boolean { - return !isRequired || validateRequiredFields(config); +function computeCanClose(): boolean { + return true; } describe("ENDPOINT_PROVIDERS registry", () => { @@ -132,184 +44,188 @@ describe("ENDPOINT_PROVIDERS registry", () => { } }); - it("has unique keys", () => { - const keys = ENDPOINT_PROVIDERS.map((p) => p.key); - expect(new Set(keys).size).toBe(keys.length); - }); - - it("contains exactly DashScope, Kling, Vidu", () => { - expect(ENDPOINT_PROVIDERS).toHaveLength(3); + it("contains exactly Ark / Seedance, DashScope, Kling, Vidu", () => { + expect(ENDPOINT_PROVIDERS).toHaveLength(4); const labels = ENDPOINT_PROVIDERS.map((p) => p.label); - expect(labels).toEqual(expect.arrayContaining(["DashScope", "Kling", "Vidu"])); + expect(labels).toEqual(expect.arrayContaining(["Ark / Seedance", "DashScope", "Kling", "Vidu"])); }); }); -describe("validateRequiredFields", () => { - it("returns false when DashScope key is missing", () => { - expect(validateRequiredFields(DEFAULT_CONFIG)).toBe(false); +describe("storage provider normalization", () => { + it("accepts tos and oss", () => { + expect(normalizeStorageProvider("tos")).toBe("tos"); + expect(normalizeStorageProvider("oss")).toBe("oss"); }); - it("returns true when only DashScope key is present (default provider modes)", () => { - const valid = { ...DEFAULT_CONFIG, DASHSCOPE_API_KEY: "sk-test" }; - expect(validateRequiredFields(valid)).toBe(true); + it("falls back to local mode for unknown values", () => { + expect(normalizeStorageProvider("unexpected")).toBe(""); + expect(normalizeStorageProvider(undefined)).toBe(""); }); +}); - it("does not require OSS or Alibaba credentials", () => { - const valid = { - ...DEFAULT_CONFIG, - DASHSCOPE_API_KEY: "sk-test", - ALIBABA_CLOUD_ACCESS_KEY_ID: "", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", - OSS_BUCKET_NAME: "", - OSS_ENDPOINT: "", - }; - expect(validateRequiredFields(valid)).toBe(true); +describe("image provider normalization", () => { + it("defaults to openai-compatible for unknown values", () => { + expect(normalizeImageProvider("openai")).toBe("openai"); + expect(normalizeImageProvider("dashscope")).toBe("dashscope"); + expect(normalizeImageProvider("unexpected")).toBe("openai"); }); +}); - it("requires Kling vendor credentials when KLING_PROVIDER_MODE=vendor", () => { - const invalid = { - ...DEFAULT_CONFIG, - DASHSCOPE_API_KEY: "sk-test", - KLING_PROVIDER_MODE: "vendor" as const, - KLING_ACCESS_KEY: "", - KLING_SECRET_KEY: "", - }; - expect(validateRequiredFields(invalid)).toBe(false); - - const valid = { - ...invalid, - KLING_ACCESS_KEY: "kling-ak", - KLING_SECRET_KEY: "kling-sk", - }; - expect(validateRequiredFields(valid)).toBe(true); +describe("edit provider normalization", () => { + it("defaults to openai-compatible for unknown values", () => { + expect(normalizeEditProvider("openai")).toBe("openai"); + expect(normalizeEditProvider("dashscope")).toBe("dashscope"); + expect(normalizeEditProvider("unexpected")).toBe("openai"); }); +}); - it("requires Vidu API key when VIDU_PROVIDER_MODE=vendor", () => { - const invalid = { - ...DEFAULT_CONFIG, - DASHSCOPE_API_KEY: "sk-test", - VIDU_PROVIDER_MODE: "vendor" as const, - VIDU_API_KEY: "", - }; - expect(validateRequiredFields(invalid)).toBe(false); - - const valid = { - ...invalid, - VIDU_API_KEY: "vidu-key", - }; - expect(validateRequiredFields(valid)).toBe(true); +describe("tts provider normalization", () => { + it("defaults to openai-compatible for unknown values", () => { + expect(normalizeTtsProvider("openai")).toBe("openai"); + expect(normalizeTtsProvider("dashscope")).toBe("dashscope"); + expect(normalizeTtsProvider("unexpected")).toBe("openai"); }); +}); - it("trims whitespace before validation", () => { - const invalid = { - ...DEFAULT_CONFIG, - DASHSCOPE_API_KEY: " ", - }; - expect(validateRequiredFields(invalid)).toBe(false); - - const valid = { - ...DEFAULT_CONFIG, - DASHSCOPE_API_KEY: " sk-test ", - KLING_PROVIDER_MODE: "vendor" as const, - KLING_ACCESS_KEY: " kling-ak ", - KLING_SECRET_KEY: " kling-sk ", - VIDU_PROVIDER_MODE: "vendor" as const, - VIDU_API_KEY: " vidu-key ", - }; - expect(validateRequiredFields(valid)).toBe(true); +describe("canSaveConfig", () => { + it("allows saving when no API keys are configured yet", () => { + expect(canSaveConfig()).toBe(true); }); }); -describe("applyChange (handleChange logic)", () => { +describe("applyChange", () => { it("updates a single field immutably", () => { - const updated = applyChange(DEFAULT_CONFIG, "DASHSCOPE_API_KEY", "sk-new"); - expect(updated.DASHSCOPE_API_KEY).toBe("sk-new"); - expect(DEFAULT_CONFIG.DASHSCOPE_API_KEY).toBe(""); - }); - - it("preserves provider mode fields when changing key values", () => { - const base = { ...DEFAULT_CONFIG, KLING_PROVIDER_MODE: "vendor" as const }; - const updated = applyChange(base, "DASHSCOPE_API_KEY", "sk-new"); - expect(updated.KLING_PROVIDER_MODE).toBe("vendor"); + const updated = applyChange("OPENAI_IMAGE_MODEL", "image-model-x"); + expect(updated.OPENAI_IMAGE_MODEL).toBe("image-model-x"); + expect(DEFAULT_ENV_CONFIG.OPENAI_IMAGE_MODEL).toBe("gpt-image2"); }); }); -describe("applyEndpointChange (handleEndpointChange logic)", () => { - it("adds and updates endpoint overrides immutably", () => { - const added = applyEndpointChange(DEFAULT_CONFIG, "DASHSCOPE_BASE_URL", "https://intl.example.com"); - expect(added.endpoint_overrides.DASHSCOPE_BASE_URL).toBe("https://intl.example.com"); - - const updated = applyEndpointChange(added, "DASHSCOPE_BASE_URL", "https://new.example.com"); - expect(updated.endpoint_overrides.DASHSCOPE_BASE_URL).toBe("https://new.example.com"); - expect(added.endpoint_overrides.DASHSCOPE_BASE_URL).toBe("https://intl.example.com"); +describe("DEFAULT_ENV_CONFIG", () => { + it("uses gpt-image2 defaults for both image generation and image edit", () => { + expect(DEFAULT_ENV_CONFIG.IMAGE_EDIT_PROVIDER).toBe("openai"); + expect(DEFAULT_ENV_CONFIG.OPENAI_IMAGE_BASE_URL).toBe("https://api.bltcy.ai/v1"); + expect(DEFAULT_ENV_CONFIG.OPENAI_IMAGE_MODEL).toBe("gpt-image2"); + expect(DEFAULT_ENV_CONFIG.OPENAI_IMAGE_EDIT_BASE_URL).toBe("https://api.bltcy.ai/v1"); + expect(DEFAULT_ENV_CONFIG.OPENAI_IMAGE_EDIT_MODEL).toBe("gpt-image2"); }); +}); - it("preserves other overrides when changing one", () => { - const base = { - ...DEFAULT_CONFIG, - endpoint_overrides: { - DASHSCOPE_BASE_URL: "https://ds.example.com", - KLING_BASE_URL: "https://kling.example.com", - }, - }; - const updated = applyEndpointChange(base, "VIDU_BASE_URL", "https://vidu.example.com"); - expect(updated.endpoint_overrides.KLING_BASE_URL).toBe("https://kling.example.com"); - expect(updated.endpoint_overrides.VIDU_BASE_URL).toBe("https://vidu.example.com"); +describe("applyEndpointChange", () => { + it("adds endpoint overrides immutably", () => { + const updated = applyEndpointChange("DASHSCOPE_BASE_URL", "https://intl.example.com"); + expect(updated.endpoint_overrides.DASHSCOPE_BASE_URL).toBe("https://intl.example.com"); + expect(DEFAULT_ENV_CONFIG.endpoint_overrides.DASHSCOPE_BASE_URL).toBeUndefined(); }); }); -describe("normalizeApiResponse", () => { +describe("normalizeEnvConfig", () => { it("preserves provider-mode fields from API response", () => { - const apiData = { - DASHSCOPE_API_KEY: "sk-from-api", + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { KLING_PROVIDER_MODE: "vendor", VIDU_PROVIDER_MODE: "vendor", PIXVERSE_PROVIDER_MODE: "dashscope", endpoint_overrides: { KLING_BASE_URL: "https://custom-kling.example.com" }, - }; - const result = normalizeApiResponse(DEFAULT_CONFIG, apiData); + }); + expect(result.KLING_PROVIDER_MODE).toBe("vendor"); expect(result.VIDU_PROVIDER_MODE).toBe("vendor"); expect(result.endpoint_overrides).toEqual({ KLING_BASE_URL: "https://custom-kling.example.com" }); }); - it("falls back missing or invalid provider modes to dashscope", () => { - const result = normalizeApiResponse(DEFAULT_CONFIG, { - KLING_PROVIDER_MODE: "unexpected-value", - endpoint_overrides: {}, + it("maps object storage fields from new generic API response", () => { + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { + OBJECT_STORAGE_PROVIDER: "tos", + OBJECT_STORAGE_BUCKET_NAME: "ark-auto-2104181120-cn-beijing-default", + OBJECT_STORAGE_ENDPOINT: "https://tos-cn-beijing.volces.com", + OBJECT_STORAGE_REGION: "cn-beijing", + OBJECT_STORAGE_BASE_PATH: "seedance-inputs", }); - expect(result.KLING_PROVIDER_MODE).toBe("dashscope"); - expect(result.VIDU_PROVIDER_MODE).toBe("dashscope"); - expect(result.PIXVERSE_PROVIDER_MODE).toBe("dashscope"); + + expect(result.OBJECT_STORAGE_PROVIDER).toBe("tos"); + expect(result.OBJECT_STORAGE_BUCKET_NAME).toBe("ark-auto-2104181120-cn-beijing-default"); + expect(result.OBJECT_STORAGE_ENDPOINT).toBe("https://tos-cn-beijing.volces.com"); + expect(result.OBJECT_STORAGE_REGION).toBe("cn-beijing"); + expect(result.OBJECT_STORAGE_BASE_PATH).toBe("seedance-inputs"); }); - it("preserves existing endpoint overrides when API omits endpoint_overrides", () => { - const existing = { - ...DEFAULT_CONFIG, - endpoint_overrides: { DASHSCOPE_BASE_URL: "https://existing.example.com" }, - }; - const result = normalizeApiResponse(existing, { DASHSCOPE_API_KEY: "sk-updated" }); - expect(result.endpoint_overrides).toEqual({ DASHSCOPE_BASE_URL: "https://existing.example.com" }); + it("backfills generic object storage fields from legacy OSS response", () => { + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { + OSS_BUCKET_NAME: "legacy-bucket", + OSS_ENDPOINT: "oss-cn-beijing.aliyuncs.com", + OSS_BASE_PATH: "legacy-prefix", + }); + + expect(result.OBJECT_STORAGE_BUCKET_NAME).toBe("legacy-bucket"); + expect(result.OBJECT_STORAGE_ENDPOINT).toBe("oss-cn-beijing.aliyuncs.com"); + expect(result.OBJECT_STORAGE_BASE_PATH).toBe("legacy-prefix"); }); -}); -describe("computeCanClose", () => { - it("returns true when dialog is not required", () => { - expect(computeCanClose(false, DEFAULT_CONFIG)).toBe(true); + it("preserves image provider fields from API response", () => { + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { + IMAGE_PROVIDER: "dashscope", + IMAGE_EDIT_PROVIDER: "openai", + OPENAI_IMAGE_BASE_URL: "https://image.example.com/v1", + OPENAI_IMAGE_EDIT_BASE_URL: "https://edit.example.com/v1", + OPENAI_IMAGE_MODEL: "image-model-x", + OPENAI_IMAGE_EDIT_MODEL: "image-model-edit", + }); + + expect(result.IMAGE_PROVIDER).toBe("dashscope"); + expect(result.IMAGE_EDIT_PROVIDER).toBe("openai"); + expect(result.OPENAI_IMAGE_BASE_URL).toBe("https://image.example.com/v1"); + expect(result.OPENAI_IMAGE_EDIT_BASE_URL).toBe("https://edit.example.com/v1"); + expect(result.OPENAI_IMAGE_MODEL).toBe("image-model-x"); + expect(result.OPENAI_IMAGE_EDIT_MODEL).toBe("image-model-edit"); + }); + + it("preserves the backend Image2 startup check from API response", () => { + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { + image_model_startup_check: { + status: "ok", + expected_image_model: "gpt-image2", + expected_image_edit_model: "gpt-image2", + image_model: "gpt-image2", + image_edit_model: "gpt-image2", + message: "Image2 startup check passed", + }, + }); + + expect(result.image_model_startup_check?.status).toBe("ok"); + expect(result.image_model_startup_check?.image_model).toBe("gpt-image2"); + expect(result.image_model_startup_check?.image_edit_model).toBe("gpt-image2"); }); - it("blocks closing required dialog until DashScope key is set", () => { - expect(computeCanClose(true, DEFAULT_CONFIG)).toBe(false); - const valid = { ...DEFAULT_CONFIG, DASHSCOPE_API_KEY: "sk-test" }; - expect(computeCanClose(true, valid)).toBe(true); + it("preserves tts and multimodal fields from API response", () => { + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { + TTS_PROVIDER: "dashscope", + OPENAI_TTS_BASE_URL: "https://tts.example.com/v1", + OPENAI_TTS_MODEL: "gpt-4o-mini-tts", + OPENAI_MULTIMODAL_BASE_URL: "https://mm.example.com/v1", + OPENAI_MULTIMODAL_MODEL: "qwen-vl-max", + }); + + expect(result.TTS_PROVIDER).toBe("dashscope"); + expect(result.OPENAI_TTS_BASE_URL).toBe("https://tts.example.com/v1"); + expect(result.OPENAI_TTS_MODEL).toBe("gpt-4o-mini-tts"); + expect(result.OPENAI_MULTIMODAL_BASE_URL).toBe("https://mm.example.com/v1"); + expect(result.OPENAI_MULTIMODAL_MODEL).toBe("qwen-vl-max"); }); - it("blocks closing required dialog in vendor mode when vendor keys are missing", () => { - const invalid = { - ...DEFAULT_CONFIG, - DASHSCOPE_API_KEY: "sk-test", - KLING_PROVIDER_MODE: "vendor" as const, - }; - expect(computeCanClose(true, invalid)).toBe(false); + it("keeps recommended defaults when API returns empty model fields", () => { + const result = normalizeEnvConfig(DEFAULT_ENV_CONFIG, { + OPENAI_TTS_MODEL: "", + OPENAI_MULTIMODAL_MODEL: "", + OPENAI_MODEL: "qwen3.6-plus", + }); + + expect(result.OPENAI_TTS_MODEL).toBe(DEFAULT_ENV_CONFIG.OPENAI_TTS_MODEL); + expect(result.OPENAI_MULTIMODAL_MODEL).toBe(DEFAULT_ENV_CONFIG.OPENAI_MULTIMODAL_MODEL); + }); +}); + +describe("computeCanClose", () => { + it("always allows closing the settings panel", () => { + expect(computeCanClose()).toBe(true); }); }); diff --git a/frontend/src/__tests__/image-prompt-recipes.test.ts b/frontend/src/__tests__/image-prompt-recipes.test.ts new file mode 100644 index 00000000..802fe233 --- /dev/null +++ b/frontend/src/__tests__/image-prompt-recipes.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + applyImagePromptBlock, + buildDefaultImagePrompt, + detectImageStyleMode, + getImagePromptTemplates, +} from "@/lib/image-prompt-recipes"; + +describe("image prompt recipes", () => { + it("detects photoreal style mode from style prompt", () => { + expect(detectImageStyleMode("真人电影感,写实摄影")).toBe("photoreal"); + }); + + it("builds a live-action leaning default character prompt", () => { + const prompt = buildDefaultImagePrompt({ + target: "full_body", + name: "Lin", + description: "Short black hair and a charcoal trench coat", + stylePrompt: "写实电影感", + }); + + expect(prompt).toContain("Live-action human subject"); + expect(prompt).toContain("Avoid anime rendering"); + expect(prompt).toContain("Full-body hero reference of Lin."); + }); + + it("filters stylized headshot template when style is anime-like", () => { + const items = getImagePromptTemplates({ + target: "headshot", + stylePrompt: "动漫插画角色", + }); + + const ids = items.map((item) => item.id); + expect(ids).toContain("headshot-stylized-key-visual"); + }); + + it("appends image prompt blocks with spacing", () => { + expect(applyImagePromptBlock("base prompt", "extra detail", "append")).toBe( + "base prompt\n\nextra detail", + ); + }); +}); diff --git a/frontend/src/__tests__/project-store-delete.test.ts b/frontend/src/__tests__/project-store-delete.test.ts new file mode 100644 index 00000000..320c5d2e --- /dev/null +++ b/frontend/src/__tests__/project-store-delete.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { deleteProjectMock } = vi.hoisted(() => ({ + deleteProjectMock: vi.fn(), +})); + +const localStorageMock = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0, +}; + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + configurable: true, +}); + +Object.defineProperty(globalThis, "window", { + value: { localStorage: localStorageMock }, + configurable: true, +}); + +vi.mock("@/lib/api", () => ({ + api: { + deleteProject: deleteProjectMock, + }, +})); + +import { useProjectStore } from "@/store/projectStore"; + +describe("project store delete flow", () => { + let errorSpy: ReturnType | null = null; + let warnSpy: ReturnType | null = null; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + deleteProjectMock.mockReset(); + useProjectStore.setState({ + projects: [ + { + id: "project-1", + title: "测试项目", + originalText: "", + characters: [], + scenes: [], + props: [], + frames: [], + status: "completed", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }, + ], + currentProject: { + id: "project-1", + title: "测试项目", + originalText: "", + characters: [], + scenes: [], + props: [], + frames: [], + status: "completed", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }, + }); + }); + + afterEach(() => { + errorSpy?.mockRestore(); + warnSpy?.mockRestore(); + errorSpy = null; + warnSpy = null; + }); + + it("keeps local state intact when backend delete fails", async () => { + deleteProjectMock.mockRejectedValue(new Error("boom")); + + await expect(useProjectStore.getState().deleteProject("project-1")).rejects.toThrow("boom"); + + const state = useProjectStore.getState(); + expect(state.projects).toHaveLength(1); + expect(state.currentProject?.id).toBe("project-1"); + }); + + it("removes the project after a successful backend delete", async () => { + deleteProjectMock.mockResolvedValue({ status: "deleted" }); + + await useProjectStore.getState().deleteProject("project-1"); + + const state = useProjectStore.getState(); + expect(state.projects).toHaveLength(0); + expect(state.currentProject).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/prompt-config-presets.test.ts b/frontend/src/__tests__/prompt-config-presets.test.ts new file mode 100644 index 00000000..64685d47 --- /dev/null +++ b/frontend/src/__tests__/prompt-config-presets.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { getPromptConfigPresets } from "@/lib/prompt-config-presets"; + +describe("prompt config presets", () => { + it("returns storyboard presets with placeholders intact", () => { + const items = getPromptConfigPresets("storyboard_polish"); + + expect(items.length).toBeGreaterThan(0); + expect(items[0].prompt).toContain("{ASSETS}"); + expect(items[0].prompt).toContain("{DRAFT}"); + }); + + it("returns r2v presets for multi-character blocking", () => { + const items = getPromptConfigPresets("r2v_polish"); + const ids = items.map((item) => item.id); + + expect(ids).toContain("r2v-dialogue-blocking"); + expect(ids).toContain("r2v-action-blocking"); + }); +}); diff --git a/frontend/src/__tests__/prompt-quality.test.ts b/frontend/src/__tests__/prompt-quality.test.ts new file mode 100644 index 00000000..db4ec3e4 --- /dev/null +++ b/frontend/src/__tests__/prompt-quality.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + hasBlockingPromptIssues, + inspectImagePrompt, + inspectStoryboardPrompt, + inspectVideoPrompt, +} from "@/lib/prompt-quality"; + +describe("prompt quality inspector", () => { + it("blocks fixed-camera and movement conflicts for video prompts", () => { + const issues = inspectVideoPrompt({ + prompt: "Static camera, then camera pans left while the hero runs forward.", + workflow: "standard", + generationMode: "i2v", + }); + + expect(hasBlockingPromptIssues(issues)).toBe(true); + expect(issues.some((issue) => issue.code === "static_vs_motion")).toBe(true); + }); + + it("warns when realistic image prompts lack human realism cues", () => { + const issues = inspectImagePrompt({ + prompt: "Character portrait of Lin with a calm expression.", + target: "headshot", + stylePrompt: "真人写实电影感", + }); + + expect(issues.some((issue) => issue.code === "realism_signal_weak")).toBe(true); + }); + + it("warns about continuity risk for same-scene storyboard prompts", () => { + const issues = inspectStoryboardPrompt({ + prompt: "Wide shot of the room as the hero walks to the table.", + sameSceneContinuity: true, + }); + + expect(issues.some((issue) => issue.code === "storyboard_continuity_missing")).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/seedance-preview.test.ts b/frontend/src/__tests__/seedance-preview.test.ts new file mode 100644 index 00000000..580f0f0d --- /dev/null +++ b/frontend/src/__tests__/seedance-preview.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSeedancePayloadPreview, + buildSeedancePayloadPreviews, + getSeedanceLowCostPreset, + getSeedanceSubmissionState, +} from '@/lib/seedance'; + +describe('buildSeedancePayloadPreview', () => { + it('标准工作流不会带 workflow_mode', () => { + const payload = buildSeedancePayloadPreview({ + prompt: 'cinematic close-up', + model: 'doubao-seedance-2-0-260128', + duration: 5, + resolution: '480p', + aspectRatio: '16:9', + watermark: true, + cameraFixed: false, + generateAudio: false, + referenceMode: 'image', + workflow: 'standard', + extendMode: 'continue', + editMode: 'subject_replace', + imageUrls: ['https://example.com/hero.png'], + referenceVideoUrls: ['https://example.com/ref.mp4'], + referenceAudioUrl: 'https://example.com/ref.wav', + }); + + expect(payload.reference_mode).toBe('image'); + expect(payload.workflow).toBe('standard'); + expect(payload).not.toHaveProperty('workflow_mode'); + expect(payload.content).toEqual([ + { type: 'text', text: 'cinematic close-up' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/hero.png' }, + }, + ]); + }); +}); + +describe('buildSeedancePayloadPreviews', () => { + it('多图组合参考会展开成多份 payload,并保留视频/音频参考', () => { + const previews = buildSeedancePayloadPreviews({ + prompt: 'hero keeps running through neon rain', + model: 'doubao-seedance-2-0-260128', + duration: 5, + resolution: '480p', + aspectRatio: '9:16', + watermark: true, + cameraFixed: true, + generateAudio: false, + seed: 42, + referenceMode: 'combo', + workflow: 'edit', + extendMode: 'continue', + editMode: 'object_edit', + imageUrls: [ + 'https://example.com/frame-1.png', + 'https://example.com/frame-2.png', + ], + referenceVideoUrls: ['https://example.com/source.mp4'], + referenceAudioUrl: 'https://example.com/guide.wav', + }); + + expect(previews).toHaveLength(2); + expect(previews[0].label).toBe('任务 1/2'); + expect(previews[1].label).toBe('任务 2/2'); + + const firstPayload = previews[0].payload; + const secondPayload = previews[1].payload; + + expect(firstPayload.workflow_mode).toBe('object_edit'); + expect(secondPayload.workflow_mode).toBe('object_edit'); + expect(firstPayload.content).toEqual([ + { type: 'text', text: 'hero keeps running through neon rain' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/frame-1.png' }, + }, + { + type: 'video_url', + video_url: { url: 'https://example.com/source.mp4' }, + }, + { + type: 'audio_url', + audio_url: { url: 'https://example.com/guide.wav' }, + }, + ]); + expect(secondPayload.content).toEqual([ + { type: 'text', text: 'hero keeps running through neon rain' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/frame-2.png' }, + }, + { + type: 'video_url', + video_url: { url: 'https://example.com/source.mp4' }, + }, + { + type: 'audio_url', + audio_url: { url: 'https://example.com/guide.wav' }, + }, + ]); + }); + + it('视频参考模式会忽略图片和音频,只保留单份视频 payload', () => { + const previews = buildSeedancePayloadPreviews({ + prompt: 'continue the action', + model: 'doubao-seedance-2-0-260128', + duration: 5, + resolution: '720p', + aspectRatio: '16:9', + watermark: false, + cameraFixed: false, + generateAudio: true, + referenceMode: 'video', + workflow: 'extend', + extendMode: 'prepend', + editMode: 'subject_replace', + imageUrls: ['https://example.com/ignored.png'], + referenceVideoUrls: ['https://example.com/source.mp4'], + referenceAudioUrl: 'https://example.com/ignored.wav', + }); + + expect(previews).toHaveLength(1); + expect(previews[0].payload.content).toEqual([ + { type: 'text', text: 'continue the action' }, + { + type: 'video_url', + video_url: { url: 'https://example.com/source.mp4' }, + }, + ]); + }); +}); + +describe('getSeedanceSubmissionState', () => { + it('手动开启仅预览时,优先锁到 preview', () => { + const state = getSeedanceSubmissionState({ + previewOnly: true, + workflow: 'standard', + referenceMode: 'image', + imageUrls: ['https://example.com/hero.png'], + referenceVideoUrls: [], + referenceAudioUrl: '', + }); + + expect(state).toEqual({ + mode: 'preview', + reason: 'manual_preview', + }); + }); + + it('非标准工作流缺少有效参考视频时会自动锁预览', () => { + const state = getSeedanceSubmissionState({ + previewOnly: false, + workflow: 'edit', + referenceMode: 'image', + imageUrls: ['https://example.com/hero.png'], + referenceVideoUrls: ['https://example.com/ignored.mp4'], + referenceAudioUrl: '', + }); + + expect(state).toEqual({ + mode: 'preview', + reason: 'workflow_missing_video', + }); + }); + + it('非标准工作流在视频参考就绪后可以真实提交', () => { + const state = getSeedanceSubmissionState({ + previewOnly: false, + workflow: 'extend', + referenceMode: 'video', + imageUrls: [], + referenceVideoUrls: ['https://example.com/source.mp4'], + referenceAudioUrl: '', + }); + + expect(state).toEqual({ + mode: 'submit', + reason: 'ready', + }); + }); +}); + +describe('getSeedanceLowCostPreset', () => { + it('返回最低成本测试组合', () => { + expect(getSeedanceLowCostPreset()).toEqual({ + duration: 5, + resolution: '480p', + batchSize: 1, + generateAudio: false, + aspectRatio: 'adaptive', + watermark: true, + cameraFixed: false, + seedanceWorkflow: 'standard', + seedanceReferenceMode: 'image', + }); + }); +}); diff --git a/frontend/src/__tests__/seedance-prompts.test.ts b/frontend/src/__tests__/seedance-prompts.test.ts new file mode 100644 index 00000000..33485bbb --- /dev/null +++ b/frontend/src/__tests__/seedance-prompts.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + applySeedancePromptBlock, + getSeedancePromptScaffolds, + getSeedancePromptTemplates, +} from "@/lib/seedance-prompts"; + +describe("seedance prompt library", () => { + it("filters workflow scaffolds for extend mode", () => { + const items = getSeedancePromptScaffolds({ + generationMode: "i2v", + workflow: "extend", + }); + + const ids = items.map((item) => item.id); + expect(ids).toContain("workflow-extend"); + expect(ids).not.toContain("workflow-edit"); + }); + + it("filters workflow-mode specific templates for object_edit", () => { + const items = getSeedancePromptTemplates({ + generationMode: "i2v", + workflow: "edit", + workflowMode: "object_edit", + }); + + const ids = items.map((item) => item.id); + expect(ids).toContain("workflow-edit-object-cleanup"); + expect(ids).not.toContain("workflow-edit-subject-swap-clean"); + }); + + it("filters templates for r2v context", () => { + const items = getSeedancePromptTemplates({ + generationMode: "r2v", + workflow: "standard", + }); + + const ids = items.map((item) => item.id); + expect(ids).toContain("r2v-dialogue-closeup"); + expect(ids).toContain("short-drama-emotional-turn"); + expect(ids).not.toContain("cinematic-neon-reveal"); + }); + + it("replaces current prompt when mode is replace", () => { + expect(applySeedancePromptBlock("old prompt", "new template", "replace")).toBe( + "new template", + ); + }); + + it("appends current prompt with spacing when mode is append", () => { + expect(applySeedancePromptBlock("old prompt", "new template", "append")).toBe( + "old prompt\n\nnew template", + ); + }); +}); diff --git a/frontend/src/__tests__/storyboard-references.test.ts b/frontend/src/__tests__/storyboard-references.test.ts new file mode 100644 index 00000000..54ee12d9 --- /dev/null +++ b/frontend/src/__tests__/storyboard-references.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; + +import { + buildStoryboardCompositionData, + buildStoryboardReferencePreview, + recommendCodexImagegenMode, +} from "../lib/storyboard-references"; + +const project = { + scenes: [ + { + id: "scene-1", + name: "夜巷", + image_url: "https://example.com/current-scene.png", + }, + ], + characters: [ + { + id: "char-1", + name: "林夏", + full_body_asset: { + selected_id: "char-current", + variants: [ + { + id: "char-current", + url: "https://example.com/current-character.png", + }, + ], + }, + }, + ], + props: [ + { + id: "prop-1", + name: "红色纸鹤", + image_url: "https://example.com/current-prop.png", + }, + ], +}; + +describe("buildStoryboardCompositionData", () => { + it("rebuilds managed references instead of carrying stale urls forward", () => { + const frame = { + id: "frame-1", + scene_id: "scene-1", + character_ids: ["char-1"], + prop_ids: ["prop-1"], + composition_data: { + reference_binding_version: 1, + reference_image_url: "https://example.com/stale-first.png", + reference_image_urls: [ + "https://example.com/stale-first.png", + "https://example.com/stale-second.png", + "https://example.com/current-scene.png", + ], + }, + }; + + const composition = buildStoryboardCompositionData(project, frame, { + continuityLock: false, + includeStyleReferences: false, + }); + + expect(composition.reference_image_urls).toEqual([ + "https://example.com/current-scene.png", + "https://example.com/current-character.png", + "https://example.com/current-prop.png", + ]); + expect(composition.reference_image_urls).not.toContain("https://example.com/stale-first.png"); + expect(composition.reference_image_urls).not.toContain("https://example.com/stale-second.png"); + }); + + it("preserves unmanaged custom reference urls", () => { + const frame = { + id: "frame-1", + scene_id: "scene-1", + character_ids: ["char-1"], + prop_ids: [], + composition_data: { + reference_image_urls: ["https://example.com/custom-reference.png"], + }, + }; + + const composition = buildStoryboardCompositionData(project, frame, { + continuityLock: false, + includeStyleReferences: false, + }); + + expect(composition.reference_image_urls).toEqual([ + "https://example.com/custom-reference.png", + "https://example.com/current-scene.png", + "https://example.com/current-character.png", + ]); + }); +}); + +describe("recommendCodexImagegenMode", () => { + it("keeps small high-value reference sets on safe direct mode", () => { + const frame = { + id: "frame-2", + scene_id: "scene-1", + character_ids: ["char-1"], + prop_ids: ["prop-1"], + composition_data: null, + }; + const preview = buildStoryboardReferencePreview({ + ...project, + frames: [ + { + id: "frame-1", + scene_id: "scene-1", + rendered_image_url: "https://example.com/prev-frame.png", + }, + frame, + ], + }, frame, { + continuityLock: true, + includeStyleReferences: false, + }); + + const recommendation = recommendCodexImagegenMode(preview); + + expect(recommendation.mode).toBe("safe_refs_only"); + expect(recommendation.metrics.readyCount).toBe(4); + expect(recommendation.score).toBeLessThan(60); + }); + + it("recommends two-stage mode for multi-reference identity-heavy frames", () => { + const frame = { + id: "frame-3", + scene_id: "scene-1", + character_ids: ["char-1", "char-2"], + prop_ids: ["prop-1", "prop-2"], + composition_data: null, + }; + const complexProject = { + ...project, + frames: [ + { + id: "frame-2", + scene_id: "scene-1", + rendered_image_url: "https://example.com/prev-frame.png", + }, + frame, + ], + characters: [ + ...(project.characters || []), + { + id: "char-2", + name: "周沉", + full_body_asset: { + selected_id: "char-2-current", + variants: [ + { + id: "char-2-current", + url: "https://example.com/current-character-2.png", + }, + ], + }, + }, + ], + props: [ + ...(project.props || []), + { + id: "prop-2", + name: "白熊", + image_url: "https://example.com/current-prop-2.png", + }, + ], + }; + const composition = buildStoryboardCompositionData(complexProject, frame, { + continuityLock: true, + includeStyleReferences: false, + codexRecommendationIncludeStyleReferences: true, + }); + + expect(composition.codex_imagegen_recommended_mode).toBe("two_stage_high_consistency"); + expect(composition.codex_imagegen_recommendation).toMatchObject({ + mode: "two_stage_high_consistency", + metrics: { + readyCount: 6, + characterCount: 2, + propCount: 2, + }, + }); + }); + + it("uses backend recommendation payloads and normalizes snake_case metrics", () => { + const frame = { + id: "frame-backend", + scene_id: "scene-1", + character_ids: ["char-1"], + prop_ids: ["prop-1"], + composition_data: { + codex_imagegen_recommendation: { + mode: "two_stage_high_consistency", + score: 72, + reason: "后端已计算。", + metrics: { + ready_count: 6, + total_count: 7, + required_ready_count: 5, + missing_required_count: 0, + continuity_count: 1, + scene_count: 1, + character_count: 2, + prop_count: 2, + style_count: 0, + identity_count: 4, + environment_count: 2, + locked_count: 3, + }, + }, + }, + }; + + const composition = buildStoryboardCompositionData(project, frame, { + continuityLock: false, + includeStyleReferences: false, + }); + + expect(composition.codex_imagegen_recommended_mode).toBe("two_stage_high_consistency"); + expect(composition.codex_imagegen_recommendation.metrics).toMatchObject({ + readyCount: 6, + totalCount: 7, + characterCount: 2, + propCount: 2, + environmentCount: 2, + }); + }); +}); diff --git a/frontend/src/__tests__/video-params.test.ts b/frontend/src/__tests__/video-params.test.ts index 8e6d40ac..497e940a 100644 --- a/frontend/src/__tests__/video-params.test.ts +++ b/frontend/src/__tests__/video-params.test.ts @@ -39,6 +39,35 @@ describe('I2V_MODELS 配置', () => { }); }); +// ── Seedance 2.0 参数 ────────────────────────────────────────────────── + +describe('Seedance 2.0 模型参数', () => { + const seedance = I2V_MODELS.find(m => m.id === 'doubao-seedance-2-0-260128')!; + const p = seedance.params; + + it('支持 resolution, seed, audio', () => { + expect(p.resolution).toBeDefined(); + expect(p.seed).toBe(true); + expect(p.audio).toBe(true); + }); + + it('resolution 仅包含 480p/720p', () => { + expect(p.resolution!.options).toEqual(['480p', '720p']); + expect(p.resolution!.default).toBe('720p'); + }); + + it('不支持 Wan/Kling/Vidu 专属参数', () => { + expect(p.negativePrompt).toBeUndefined(); + expect(p.promptExtend).toBeUndefined(); + expect(p.shotType).toBeUndefined(); + expect(p.mode).toBeUndefined(); + expect(p.sound).toBeUndefined(); + expect(p.cfgScale).toBeUndefined(); + expect(p.viduAudio).toBeUndefined(); + expect(p.movementAmplitude).toBeUndefined(); + }); +}); + // ── Wan 2.6 参数 ─────────────────────────────────────────────────────── describe('Wan 2.6 模型参数', () => { @@ -258,6 +287,12 @@ describe('模型切换参数重置逻辑', () => { expect(result.resolution).toBe('720p'); }); + it('切换到 Seedance 2.0 → 分辨率默认 720p,且无 promptExtend', () => { + const result = simulateModelSwitch('doubao-seedance-2-0-260128'); + expect(result.resolution).toBe('720p'); + expect(result.promptExtend).toBe(false); + }); + it('切换到 Wan 2.2 → 无 promptExtend', () => { const result = simulateModelSwitch('wan2.2-i2v-plus'); expect(result.promptExtend).toBe(false); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 676de959..e0b89022 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,5 @@ import "./globals.css"; -import EnvConfigChecker from "@/components/EnvConfigChecker"; +import { zhCN } from "@/lib/i18n"; export default function RootLayout({ children, @@ -7,13 +7,12 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + LumenX Studio - + - {children} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1c652440..f31b0e47 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { motion } from "framer-motion"; -import { Plus, FolderOpen, RefreshCw, Library, Calendar, Play, Trash2, FileUp, X, ChevronDown, FileText } from "lucide-react"; +import { Plus, FolderOpen, RefreshCw, Library, Calendar, Play, Trash2, FileUp, X, ChevronDown, FileText, Settings, Sparkles } from "lucide-react"; import { useProjectStore, Series, Project } from "@/store/projectStore"; import ProjectCard from "@/components/project/ProjectCard"; import CreateProjectDialog from "@/components/project/CreateProjectDialog"; @@ -11,13 +11,16 @@ import CreativeCanvas from "@/components/canvas/CreativeCanvas"; import AppShell from "@/components/layout/AppShell"; import type { GlobalTab } from "@/components/layout/GlobalSidebar"; import dynamic from "next/dynamic"; -import { api } from "@/lib/api"; +import { api, type FixtureProjectSummary } from "@/lib/api"; +import { defaultLocale, messages } from "@/lib/i18n"; const ProjectClient = dynamic(() => import("@/components/project/ProjectClient"), { ssr: false }); const SeriesDetailPage = dynamic(() => import("@/components/series/SeriesDetailPage"), { ssr: false }); const ImportFileDialog = dynamic(() => import("@/components/series/ImportFileDialog"), { ssr: false }); const SettingsPage = dynamic(() => import("@/components/settings/SettingsPage"), { ssr: false }); const AssetLibraryPage = dynamic(() => import("@/components/library/AssetLibraryPage"), { ssr: false }); +const homeCopy = messages.homePage; +const commonActions = messages.common.actions; // ── Create Series Dialog ── function CreateSeriesDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { @@ -52,7 +55,7 @@ function CreateSeriesDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () className="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md shadow-2xl" >
-

新建系列

+

{homeCopy.createSeriesDialog.title}

@@ -60,22 +63,22 @@ function CreateSeriesDialog({ isOpen, onClose }: { isOpen: boolean; onClose: ()
- + setTitle(e.target.value)} - placeholder="例如:我的漫剧系列" + placeholder={homeCopy.createSeriesDialog.titlePlaceholder} className="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary transition-colors" autoFocus />
- +