diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6da06a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Do not commit real keys. Copy this file to .env.local for local use. + +# Volcengine Ark / Seedance 2.0 +SEEDANCE_API_KEY= +SEEDANCE_MODEL=doubao-seedance-2-0-260128 +SEEDANCE_API_URL=https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks +SEEDANCE_MAX_CLIPS=3 +SEEDANCE_MAX_WAIT_SEC=900 +SEEDANCE_POLL_INTERVAL_SEC=20 + +# Fish Audio / Fish Studio +FISH_AUDIO_API_KEY= +FISH_AUDIO_MODEL=s2-pro +FISH_AUDIO_VOICE_ZH= +FISH_AUDIO_VOICE_EN= diff --git a/.gitignore b/.gitignore index c18dd8d..e0b2ee4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ -__pycache__/ +node_modules +.next +.playwright-mcp +out +data +vendor +.env.local +*.log diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..90ffec2 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,9 @@ +# Notices + +This project incorporates implementation ideas from: + +- `iPythoning/ai-video-studio` — MIT License + - Repository: https://github.com/iPythoning/ai-video-studio + - Borrowed ideas: Seedance task submission/poll/download flow, Fish Audio TTS integration shape, server-side FFmpeg composition, storyboard-to-pipeline workflow. + +The upstream repository also contains `reference/huobao/` prompt methodology under CC BY-NC-SA 4.0. That non-commercial reference content is intentionally not copied into this project so this codebase can remain suitable for a standard commercial product path. diff --git a/README.md b/README.md index d28402c..a0aeb56 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,58 @@ -# AI Video Studio +# ClipForge Local -**OpenClaw skill** for AI video generation + editing. Generate video clips with [Seedance 2.0](https://www.volcengine.com/product/doubao), edit with [CapCut Mate](https://github.com/Hommy-master/capcut-mate) or FFmpeg, and export — all server-side, no desktop app needed. +本地优先的营销短视频自动剪辑工作台 MVP。上传文案、素材和对标视频后,系统会生成中英双语、TikTok / Reels / Shorts 三平台、每种语言三组变体的输出包。 -## Features +当前版本已经合并 `iPythoning/ai-video-studio` 的核心思路:Seedance 2.0 生成视频片段、Fish Audio 生成人声、FFmpeg/Remotion 合成导出。默认是本地预览模式,不消耗外部 API;创建项目时选择“API 增强”才会调用 Seedance/Fish。 -- **AI Video Generation** — Text/image-to-video via ByteDance Seedance 2.0 (Doubao) -- **Smart Editing** — Auto-compose multi-shot storyboards with captions, BGM, and transitions -- **Dual Renderer** — FFmpeg (default, server-side mp4) or CapCut Mate (Jianying draft) -- **One-command Drama / Product Promo** — `drama` subcommand runs a four-layer pipeline (script → characters/scenes → storyboard → render) with autonomous narrative-template selection; methodology ported from [huobao-drama](https://github.com/chatfire-AI/huobao-drama) (CC BY-NC-SA 4.0, see `skills/ai-video-studio/reference/huobao/ATTRIBUTION.md`) -- **OpenClaw Native** — Install as a skill, invoke via natural language through any channel (Telegram, WhatsApp, CLI) - -## One-command Scenario Video +## 运行 ```bash -# Scenario-based product promotion -python3 scripts/studio.py drama \ - --mode product \ - --idea "Smart thermos for commuters" \ - --scenario "morning run / subway / office" \ - --highlights "12h heat retention, aerospace steel" \ - --shots 6 --duration 5 --ratio 9:16 --lang en --run - -# Short drama -python3 scripts/studio.py drama \ - --mode shortdrama \ - --idea "Café reunion: one sentence uncovers a three-year misunderstanding" \ - --shots 6 --duration 8 --ratio 9:16 --lang zh --run +npm install +npm run dev ``` -Requires `ANTHROPIC_API_KEY` (Sonnet executor + Opus advisor) in addition to `SEEDANCE_API_KEY`. - -## Quick Start - -### As an OpenClaw Skill (recommended) - -```bash -# Copy to your OpenClaw workspace -cp -r skills/ai-video-studio ~/.openclaw/workspace/skills/ +打开 `http://localhost:3000`。 -# Set your Seedance API key -export SEEDANCE_API_KEY="your-key-here" +如果 3000 被占用,Next.js 会自动切到下一个可用端口。 -# Talk to your OpenClaw agent: -# "Generate a 3-shot product video, 16:9, 5 seconds each, with captions" -``` +## API 增强模式 -### Standalone CLI +复制 `.env.example` 为 `.env.local`,填入: ```bash -# Generate a single clip -python3 scripts/studio.py generate \ - --prompt "A cat yawning in golden sunlight" \ - --ratio 16:9 --duration 5 - -# Render existing clips into final video (FFmpeg) -python3 scripts/studio.py render \ - --videos clip1.mp4,clip2.mp4 \ - --captions "Scene 1,Scene 2" \ - --bgm https://example.com/music.mp3 \ - --ratio 16:9 - -# Full pipeline from storyboard -python3 scripts/studio.py pipeline storyboard.json -``` - -### Storyboard Format - -```json -{ - "title": "Product Ad", - "ratio": "16:9", - "renderer": "ffmpeg", - "shots": [ - { - "prompt": "Modern smartphone floating in space with golden particles", - "duration": 5, - "caption": "Next-Gen Design" - }, - { - "prompt": "Hands holding smartphone with holographic UI elements", - "duration": 5, - "caption": "Intelligence at Your Fingertips" - } - ], - "bgm_url": null -} +SEEDANCE_API_KEY=... +FISH_AUDIO_API_KEY=... +FISH_AUDIO_VOICE_ZH=... +FISH_AUDIO_VOICE_EN=... ``` -## Architecture +然后在 Web 工作台的“生成模式”选择 `API 增强:Seedance + Fish Audio`。为了避免误烧额度,Seedance 默认只给 `中文 / TikTok / V1` 这条主故事板生成最多 3 个视频片段,其余平台和变体复用本地合成链路。 -``` -User Intent - │ - ▼ -┌─────────────┐ ┌──────────────┐ -│ Seedance │───▶│ Video Clips │ -│ 2.0 API │ │ (.mp4 × N) │ -└─────────────┘ └──────┬───────┘ - │ - ┌──────▼───────┐ - │ Renderer │ - │ │ - │ ┌──────────┐ │ - │ │ FFmpeg │ │ ← Default: concat + subtitle burn + BGM mix - │ └──────────┘ │ - │ ┌──────────┐ │ - │ │ CapCut │ │ ← Optional: Jianying draft with effects - │ │ Mate │ │ - │ └──────────┘ │ - └──────┬───────┘ - │ - ┌──────▼───────┐ - │ Final MP4 │ - └──────────────┘ -``` - -## Commands - -| Command | Description | Dependencies | -|---------|-------------|-------------| -| `generate` | Generate a single clip via Seedance 2.0 | Seedance API key | -| `compose` | Create Jianying draft via CapCut Mate | capcut-mate service | -| `render` | FFmpeg direct render (concat + subs + BGM) | ffmpeg | -| `pipeline` | End-to-end from storyboard JSON | Seedance + ffmpeg | +## 输出 -## Environment Variables +生成结果写入 `data/outputs//`,包含: -| Variable | Purpose | Default | -|----------|---------|---------| -| `SEEDANCE_API_KEY` | Doubao Seedance API key | Built-in fallback | -| `CAPCUT_MATE_URL` | CapCut Mate service URL | `http://127.0.0.1:30000` | -| `CAPCUT_API_KEY` | Jianying cloud render key (optional) | — | -| `MEDIA_DIR` | Output directory | `/root/.openclaw/media` | +- 9:16 MP4 占位成片 +- SRT 字幕 +- 平台标题、描述、标签和质检报告 +- 从对标视频抽象出的 `Style Blueprint` -## Prerequisites +## 架构 -- Python 3.11+ -- `ffmpeg` (for render mode) -- `requests` + `pyyaml` (pip) -- [CapCut Mate](https://github.com/Hommy-master/capcut-mate) (optional, for compose mode) -- [Seedance 2.0 API access](https://www.volcengine.com/product/doubao) (for generate/pipeline) +- Next.js Web 工作台 +- Node.js API 路由负责上传、任务编排和输出 +- Node 内置 SQLite 保存项目、素材清单、风格蓝图、分镜和任务 +- FFmpeg 生成本地可播放 MP4 +- Remotion 模板位于 `remotion/`,后续可替换当前 FFmpeg 占位渲染器 +- AI 能力通过 `AdapterSet` 抽象,默认本地确定性实现;API 增强模式可调用 Seedance 2.0 和 Fish Audio,后续还可替换为 WhisperX、NLLB、OpenAI、DeepL、ElevenLabs 等 -## FFmpeg Render Capabilities +## 上游合并 -- Multi-clip concatenation with automatic resolution normalization -- SRT subtitle burn-in (white text, black outline, bottom-center) -- BGM mixing (original audio 100% + BGM 30%) -- H.264 + AAC encoding, fast preset -- Sub-second render time for short videos +见 `NOTICE.md`。本项目吸收了 `iPythoning/ai-video-studio` 的 MIT 代码思路,但没有复制其 `reference/huobao/` 下的 CC BY-NC-SA 非商业 prompt 文档。 -## License +## 验证 -MIT — see [LICENSE](LICENSE). - -## Built With - -- [OpenClaw](https://openclaw.ai) — AI agent framework -- [Seedance 2.0](https://www.volcengine.com/product/doubao) — ByteDance video generation -- [CapCut Mate](https://github.com/Hommy-master/capcut-mate) — Open-source Jianying automation -- [FFmpeg](https://ffmpeg.org) — Video processing +```bash +npm run test +npm run build +``` diff --git a/app/api/integrations/route.ts b/app/api/integrations/route.ts new file mode 100644 index 0000000..d9951b1 --- /dev/null +++ b/app/api/integrations/route.ts @@ -0,0 +1,22 @@ +import {NextResponse} from "next/server"; +import {hasFishAudioConfig, hasSeedanceConfig} from "@/lib/adapters/external"; + +export const runtime = "nodejs"; + +export async function GET() { + return NextResponse.json({ + integrations: { + seedance: { + configured: hasSeedanceConfig(), + model: process.env.SEEDANCE_MODEL || "doubao-seedance-2-0-260128", + maxClipsPerStoryboard: Number(process.env.SEEDANCE_MAX_CLIPS || 3), + }, + fishAudio: { + configured: hasFishAudioConfig(), + model: process.env.FISH_AUDIO_MODEL || "s2-pro", + zhVoiceConfigured: Boolean(process.env.FISH_AUDIO_VOICE_ZH), + enVoiceConfigured: Boolean(process.env.FISH_AUDIO_VOICE_EN), + }, + }, + }); +} diff --git a/app/api/projects/[id]/generate/route.ts b/app/api/projects/[id]/generate/route.ts new file mode 100644 index 0000000..e0e2901 --- /dev/null +++ b/app/api/projects/[id]/generate/route.ts @@ -0,0 +1,17 @@ +import {NextResponse} from "next/server"; +import {generateProject} from "@/lib/pipeline"; + +export const runtime = "nodejs"; + +export async function POST(_: Request, context: {params: Promise<{id: string}>}) { + const {id} = await context.params; + try { + const project = await generateProject(id); + return NextResponse.json({project}); + } catch (error) { + return NextResponse.json( + {error: error instanceof Error ? error.message : "生成失败"}, + {status: 500}, + ); + } +} diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..4c146e6 --- /dev/null +++ b/app/api/projects/[id]/route.ts @@ -0,0 +1,13 @@ +import {NextResponse} from "next/server"; +import {getProject} from "@/lib/db"; + +export const runtime = "nodejs"; + +export async function GET(_: Request, context: {params: Promise<{id: string}>}) { + const {id} = await context.params; + const project = getProject(id); + if (!project) { + return NextResponse.json({error: "项目不存在"}, {status: 404}); + } + return NextResponse.json({project}); +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..cbe60f8 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,73 @@ +import {NextRequest, NextResponse} from "next/server"; +import path from "node:path"; +import {randomUUID} from "node:crypto"; +import {mkdir, writeFile} from "node:fs/promises"; +import {insertProject, listProjects} from "@/lib/db"; +import {ensureDataDirs, uploadDir} from "@/lib/paths"; +import {inferAssetKind, probeAsset, sanitizeFileName} from "@/lib/media"; +import type {AssetItem, Platform, ProjectBrief} from "@/lib/types"; + +export const runtime = "nodejs"; + +export async function GET() { + return NextResponse.json({projects: listProjects()}); +} + +export async function POST(request: NextRequest) { + await ensureDataDirs(); + const form = await request.formData(); + const projectId = randomUUID(); + const createdAt = new Date().toISOString(); + const projectDir = path.join(uploadDir, projectId); + await mkdir(projectDir, {recursive: true}); + + const brief: ProjectBrief = { + productName: value(form, "productName") || "未命名产品", + targetAudience: value(form, "targetAudience") || "社媒受众", + sellingPoints: value(form, "sellingPoints") || "节省剪辑时间,快速测试创意", + tone: value(form, "tone") || "直接可信", + sourceScript: value(form, "sourceScript"), + scenario: value(form, "scenario"), + generationMode: value(form, "generationMode") === "api" ? "api" : "local", + forbiddenWords: value(form, "forbiddenWords") + .split(/[,\n,]+/) + .map((word) => word.trim()) + .filter(Boolean), + targetPlatforms: ["tiktok", "reels", "shorts"] satisfies Platform[], + }; + + const assets: AssetItem[] = []; + const normalFiles = form.getAll("assets").filter(isFileWithName); + const referenceFiles = form.getAll("reference").filter(isFileWithName); + + for (const file of [...normalFiles, ...referenceFiles]) { + const isReference = referenceFiles.includes(file); + const fileName = sanitizeFileName(file.name); + const assetPath = path.join(projectDir, `${randomUUID()}-${fileName}`); + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(assetPath, buffer); + const asset: AssetItem = { + id: randomUUID(), + kind: inferAssetKind(file.name, file.type, isReference), + fileName, + path: assetPath, + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + status: "ready", + }; + assets.push(await probeAsset(asset)); + } + + const referenceAssetId = assets.find((asset) => asset.kind === "reference")?.id; + const project = insertProject(brief, {projectId, createdAt, assets, referenceAssetId}); + return NextResponse.json({project}, {status: 201}); +} + +function value(form: FormData, key: string) { + const item = form.get(key); + return typeof item === "string" ? item.trim() : ""; +} + +function isFileWithName(value: FormDataEntryValue): value is File { + return value instanceof File && value.name.length > 0 && value.size > 0; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..0587bef --- /dev/null +++ b/app/globals.css @@ -0,0 +1,401 @@ +:root { + --ink: #14120f; + --muted: #6a6258; + --paper: #f6f0e6; + --panel: #fffaf1; + --line: rgba(20, 18, 15, 0.14); + --accent: #1f8a70; + --accent-2: #e0563f; + --accent-3: #2f5f9f; + --dark: #202020; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + color: var(--ink); + background: + linear-gradient(90deg, rgba(20, 18, 15, 0.04) 1px, transparent 1px), + linear-gradient(rgba(20, 18, 15, 0.04) 1px, transparent 1px), + var(--paper); + background-size: 28px 28px; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +.shell { + min-height: 100vh; + padding: 28px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin: 0 auto 22px; + max-width: 1420px; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; +} + +.mark { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 2px solid var(--ink); + background: var(--accent); + color: white; + box-shadow: 4px 4px 0 var(--ink); +} + +.brand h1 { + margin: 0; + font-size: clamp(24px, 3vw, 44px); + line-height: 0.95; +} + +.brand p, +.topbar p { + margin: 4px 0 0; + color: var(--muted); +} + +.grid { + display: grid; + grid-template-columns: minmax(320px, 440px) minmax(0, 1fr); + gap: 20px; + max-width: 1420px; + margin: 0 auto; +} + +.panel, +.preview, +.job { + border: 2px solid var(--ink); + background: var(--panel); + box-shadow: 5px 5px 0 rgba(20, 18, 15, 0.9); +} + +.panel { + padding: 20px; +} + +.panel h2, +.preview h2 { + margin: 0 0 16px; + font-size: 20px; +} + +.field { + display: grid; + gap: 8px; + margin-bottom: 14px; +} + +.field span, +.legend { + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.field input, +.field textarea, +.field select { + width: 100%; + border: 2px solid var(--ink); + background: white; + padding: 11px 12px; + color: var(--ink); + outline: none; +} + +.field textarea { + min-height: 120px; + resize: vertical; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.uploadBox { + border: 2px dashed rgba(20, 18, 15, 0.55); + padding: 12px; + background: rgba(255, 255, 255, 0.55); +} + +.actions { + display: flex; + gap: 10px; + align-items: center; + margin-top: 18px; +} + +.apiStrip { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 4px 0 14px; +} + +.apiStrip span { + border: 2px solid var(--ink); + background: #f4d35e; + min-height: 36px; + display: flex; + align-items: center; + padding: 0 9px; + font-size: 12px; + font-weight: 800; +} + +.primary, +.secondary { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + border: 2px solid var(--ink); + padding: 0 14px; + font-weight: 800; +} + +.primary { + background: var(--accent-2); + color: white; + box-shadow: 3px 3px 0 var(--ink); +} + +.secondary { + background: white; + color: var(--ink); +} + +.primary:disabled, +.secondary:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.work { + display: grid; + gap: 18px; +} + +.heroBand { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + min-height: 260px; + border: 2px solid var(--ink); + background: var(--dark); + color: white; + overflow: hidden; + box-shadow: 5px 5px 0 rgba(20, 18, 15, 0.9); +} + +.heroCopy { + padding: 24px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.heroCopy h2 { + margin: 0; + max-width: 720px; + font-size: clamp(30px, 5vw, 72px); + line-height: 0.92; +} + +.heroCopy p { + max-width: 720px; + color: rgba(255, 255, 255, 0.75); +} + +.phone { + position: relative; + aspect-ratio: 9 / 16; + margin: 18px; + border: 3px solid white; + background: + linear-gradient(160deg, rgba(31, 138, 112, 0.85), rgba(224, 86, 63, 0.9)), + #111; + overflow: hidden; +} + +.phone::before { + content: ""; + position: absolute; + inset: 9% 9% auto; + height: 36%; + border: 2px solid rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.14); +} + +.phoneText { + position: absolute; + left: 10%; + right: 18%; + bottom: 21%; + font-size: 18px; + font-weight: 900; + line-height: 1.05; +} + +.preview { + padding: 18px; +} + +.steps { + display: grid; + grid-template-columns: repeat(6, minmax(100px, 1fr)); + gap: 8px; +} + +.step { + min-height: 74px; + border: 2px solid var(--ink); + padding: 10px; + background: white; +} + +.step b { + display: block; + font-size: 14px; +} + +.step span { + color: var(--muted); + font-size: 12px; +} + +.blueprint { + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)); + gap: 10px; +} + +.metric { + border: 2px solid var(--ink); + background: white; + padding: 12px; +} + +.metric strong { + display: block; + font-size: 22px; +} + +.jobs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 12px; +} + +.job { + padding: 14px; + box-shadow: 3px 3px 0 rgba(20, 18, 15, 0.9); +} + +.jobHead { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 2px solid var(--ink); + background: #f4d35e; + font-size: 12px; + font-weight: 800; +} + +.job ul { + margin: 0; + padding-left: 18px; + color: var(--muted); + font-size: 13px; +} + +.notice { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.error { + margin-top: 12px; + border: 2px solid #8f1d1d; + background: #fff0ed; + padding: 10px; + color: #8f1d1d; + font-weight: 700; +} + +@media (max-width: 1060px) { + .grid, + .heroBand { + grid-template-columns: 1fr; + } + + .phone { + width: min(220px, 70vw); + justify-self: center; + } + + .steps, + .blueprint, + .apiStrip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .shell { + padding: 16px; + } + + .topbar, + .row { + grid-template-columns: 1fr; + display: grid; + } + + .steps, + .blueprint, + .apiStrip { + grid-template-columns: 1fr; + } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..121253a --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..dead483 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,15 @@ +import type {Metadata} from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "ClipForge Local", + description: "本地优先的多语言社媒短视频自动剪辑工作台", +}; + +export default function RootLayout({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..eac5fdc --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import {Studio} from "./studio"; + +export default function Home() { + return ; +} diff --git a/app/studio.tsx b/app/studio.tsx new file mode 100644 index 0000000..8cba5e2 --- /dev/null +++ b/app/studio.tsx @@ -0,0 +1,341 @@ +"use client"; + +import {useEffect, useMemo, useState} from "react"; +import { + BadgeCheck, + Clapperboard, + FileVideo, + Languages, + PackageCheck, + Play, + Upload, + WandSparkles, +} from "lucide-react"; +import type {ProjectRecord, RenderJob} from "@/lib/types"; + +type ApiProject = {project: ProjectRecord}; +type IntegrationStatus = { + integrations: { + seedance: {configured: boolean; model: string; maxClipsPerStoryboard: number}; + fishAudio: {configured: boolean; model: string; zhVoiceConfigured: boolean; enVoiceConfigured: boolean}; + }; +}; + +const steps = [ + ["上传素材", "文案、图片、视频、对标视频入库"], + ["生成分镜", "脚本拆解为镜头和字幕"], + ["分析对标", "提取节奏、版式和 CTA"], + ["自动剪辑", "按语言、平台、变体生成"], + ["可编辑预览", "检查字幕安全区和文案"], + ["批量导出", "MP4、SRT、封面、报告"], +]; + +const platformName = { + tiktok: "TikTok", + reels: "Reels", + shorts: "Shorts", +}; + +const languageName = { + zh: "中文", + en: "English", +}; + +export function Studio() { + const [project, setProject] = useState(null); + const [integrations, setIntegrations] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + fetch("/api/integrations") + .then((response) => response.json()) + .then((data: IntegrationStatus) => setIntegrations(data.integrations)) + .catch(() => setIntegrations(null)); + }, []); + + const completedJobs = useMemo( + () => project?.jobs.filter((job) => job.status === "completed") ?? [], + [project], + ); + + async function createProject(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + const form = new FormData(event.currentTarget); + try { + const response = await fetch("/api/projects", {method: "POST", body: form}); + const data = (await response.json()) as Partial & {error?: string}; + if (!response.ok || !data.project) throw new Error(data.error || "项目创建失败"); + setProject(data.project); + } catch (err) { + setError(err instanceof Error ? err.message : "项目创建失败"); + } finally { + setBusy(false); + } + } + + async function generate() { + if (!project) return; + setBusy(true); + setError(""); + try { + const response = await fetch(`/api/projects/${project.id}/generate`, {method: "POST"}); + const data = (await response.json()) as Partial & {error?: string}; + if (!response.ok || !data.project) throw new Error(data.error || "生成失败"); + setProject(data.project); + } catch (err) { + setError(err instanceof Error ? err.message : "生成失败"); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
+ +
+
+

ClipForge Local

+

本地优先的多语言社媒短视频自动剪辑工作台

+
+
+

开源优先 · 可替换模型 · 9:16 批量输出

+
+ +
+
+

创建视频项目

+ +
+ + +
+