Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
__pycache__/
node_modules
.next
.playwright-mcp
out
data
vendor
.env.local
*.log
9 changes: 9 additions & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
@@ -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.
173 changes: 35 additions & 138 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/<projectId>/`,包含:

| 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
```
22 changes: 22 additions & 0 deletions app/api/integrations/route.ts
Original file line number Diff line number Diff line change
@@ -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),
},
},
});
}
17 changes: 17 additions & 0 deletions app/api/projects/[id]/generate/route.ts
Original file line number Diff line number Diff line change
@@ -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},
);
}
}
13 changes: 13 additions & 0 deletions app/api/projects/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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});
}
73 changes: 73 additions & 0 deletions app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading