
Tuzi Video Gen
- 181 installs
- 37 repo stars
- Updated April 24, 2026
- tuziapi/tuzi-skills
Use tuzi-video-gen for development tasks
About
tuzi-video-gen: A skill skill for development. This skill provides functionality for development workflows.
- tuzi-video-gen
Tuzi Video Gen by the numbers
- 181 all-time installs (skills.sh)
- Ranked #2,201 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tuziapi/tuzi-skills --skill tuzi-video-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 181 |
|---|---|
| repo stars | ★ 37 |
| Last updated | April 24, 2026 |
| Repository | tuziapi/tuzi-skills ↗ |
What it does
Use tuzi-video-gen for development tasks
Files
Video Generation (AI SDK)
Tuzi API video generation backend. Default model: veo3.1.
Script Directory
Agent Execution: 1. SKILL_DIR = this SKILL.md file's directory 2. Script path = ${SKILL_DIR}/scripts/main.ts
Step 0: Load Preferences ⛔ BLOCKING
CRITICAL: This step MUST complete BEFORE any video generation. Do NOT skip or defer.
0.1 Check API Key
echo "${TUZI_API_KEY:-not_set}"
grep -s TUZI_API_KEY .tuzi-skills/.env "$HOME/.tuzi-skills/.env"| Result | Action |
|---|---|
| Key found | Continue to Step 0.2 |
| Key NOT found | ⛔ Run API key setup (see references/config/first-time-setup.md) → Store key → Then continue |
0.2 Check EXTEND.md
test -f .tuzi-skills/tuzi-video-gen/EXTEND.md && echo "project"
test -f "$HOME/.tuzi-skills/tuzi-video-gen/EXTEND.md" && echo "user"| Result | Action |
|---|---|
| Found | Load, parse, apply settings |
| Not found | ⛔ Run first-time setup (references/config/first-time-setup.md) → Save EXTEND.md → Then continue |
| Path | Location |
|---|---|
.tuzi-skills/tuzi-video-gen/EXTEND.md | Project directory |
$HOME/.tuzi-skills/tuzi-video-gen/EXTEND.md | User home |
EXTEND.md Supports: Default model | Default seconds | Default size
Schema: references/config/preferences-schema.md
Usage
# Single video
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat walking in a garden" --video cat.mp4
# With model and duration
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "城市夜景延时" --video city.mp4 --model veo3 --seconds 8
# With reference image
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Animate this scene" --video out.mp4 --ref source.png
# From prompt file
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles prompt.md --video out.mp4
# Long video (multi-segment with ffmpeg concat)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A journey through seasons" --video long.mp4 --segments 3
# Long video with per-segment prompts
npx -y bun ${SKILL_DIR}/scripts/main.ts --video long.mp4 --segments 3 --segment-prompts seg1.md seg2.md seg3.mdOptions
| Option | Description |
|---|---|
--prompt <text>, -p | Prompt text |
--promptfiles <files...> | Read prompt from files (concatenated) |
--video <path> | Output video path (required) |
--model <id>, -m | Model ID (default: veo3.1) |
--seconds <n>, -s | Duration in seconds |
--size <WxH> | Video size (e.g., 1280x720, 16x9) |
--ref <files...> | Reference images |
| `--ref-mode reference\ | frames\ |
--segments <n> | Long video segment count (min 2) |
--segment-prompts <files...> | Per-segment prompt files |
--json | JSON output |
Models
| Model | Provider | Duration | Sizes | Image Mode |
|---|---|---|---|---|
veo3 | Veo | 8s | 16:9, 9:16 | reference |
veo3.1 (default) | Veo | 8s | 16:9, 9:16 | frames |
veo3.1-4k | Veo | 8s | 4K | frames |
sora-2 | Sora | 10/15s | 16:9, 9:16 | reference |
sora-2-pro | Sora | 10/15/25s | 16:9, 9:16, HD | reference |
kling-v1-6 | Kling | 5/10s | 16:9, 9:16, 1:1 | reference |
seedance-1.5-pro | Seedance | 5/10s | 1080p, 720p | frames |
Long Video Mode
When --segments N is specified (N >= 2):
1. Generates N video segments sequentially 2. After each segment, extracts last frame via ffmpeg 3. Last frame becomes next segment's reference image (continuity) 4. All segments concatenated via ffmpeg -f concat 5. Temporary files cleaned up
Requirements: ffmpeg must be installed.
Per-segment prompts: Use --segment-prompts to provide individual prompt files for each segment. If fewer files than segments, remaining segments use the main --prompt.
Environment Variables
| Variable | Description |
|---|---|
TUZI_API_KEY | Tuzi API key (https://api.tu-zi.com) |
TUZI_VIDEO_MODEL | Default video model (default: veo3.1) |
TUZI_BASE_URL | Custom Tuzi endpoint (default: https://api.tu-zi.com) |
Load Priority: CLI args > EXTEND.md > env vars > <cwd>/.tuzi-skills/.env > ~/.tuzi-skills/.env
Model Resolution
Priority (highest → lowest):
1. CLI: --model <id> 2. EXTEND.md: default_model 3. Env var: TUZI_VIDEO_MODEL 4. Built-in default: veo3.1
Agent MUST display model info before each generation:
- Show:
Using [model] - Show switch hint:
Switch model: --model <id> | EXTEND.md default_model | env TUZI_VIDEO_MODEL
Error Handling
- Missing API key → ⛔ MUST run API key setup from Step 0.1
- Generation failure → auto-retry once
- Business failure (content rejected) → no retry, report error
- Network error → exponential backoff (1.5x, max 60s)
- Timeout → error after 90 minutes
- Missing ffmpeg (long video mode) → clear error with install instructions
Extension Support
Custom configurations via EXTEND.md. See Step 0 for paths and supported options.
First-Time Setup
Overview
Triggered when: 1. API key missing → API key setup 2. No EXTEND.md found → full setup (model + preferences) 3. EXTEND.md found but default_model is null → model selection only
API Key Setup
Triggered when: TUZI_API_KEY not found in env, .tuzi-skills/.env, or ~/.tuzi-skills/.env.
Step 1: Guide user to obtain API key
TUZI_API_KEY 未配置。请先获取 API Key:
1. 打开 https://api.tu-zi.com/token 创建并获取 API Key
2. 视频教程:https://www.bilibili.com/video/BV1k4PqzPEKz/Step 2: Ask user for API key
Directly ask the user in plain text to paste their API key:
请粘贴你的 Tuzi API Key(以 sk- 开头):Step 3: Ask save location
header: "Save Location"
question: "API Key 保存位置?"
options:
- label: "Project (Recommended)"
description: ".tuzi-skills/.env (仅当前项目)"
- label: "User"
description: "~/.tuzi-skills/.env (所有项目共享)"Step 4: Store API key
1. Create directory: mkdir -p <chosen-path>/.tuzi-skills 2. Append: echo "TUZI_API_KEY=<key>" >> <chosen-path>/.tuzi-skills/.env 3. Confirm: "API Key 已保存到 <full-path>/.tuzi-skills/.env"
Flow 1: No EXTEND.md (Full Setup)
Use AskUserQuestion:
Question 1: Default Model
header: "Video Model"
question: "默认视频生成模型?"
options:
- label: "veo3.1 (Recommended)"
description: "Google Veo 3.1 - 8s, frames mode"
- label: "veo3"
description: "Google Veo 3 - 8s, 16:9/9:16"
- label: "sora-2"
description: "OpenAI Sora 2 - 10/15s"
- label: "kling-v1-6"
description: "Kling v1.6 - 5/10s, 多宽高比"
- label: "seedance-1.5-pro"
description: "Seedance 1.5 Pro - 5/10s"Question 2: Save Location
header: "Save"
question: "偏好保存位置?"
options:
- label: "Project (Recommended)"
description: ".tuzi-skills/ (仅当前项目)"
- label: "User"
description: "~/.tuzi-skills/ (所有项目)"Save Locations
| Choice | Path | Scope |
|---|---|---|
| Project | .tuzi-skills/tuzi-video-gen/EXTEND.md | Current project |
| User | $HOME/.tuzi-skills/tuzi-video-gen/EXTEND.md | All projects |
EXTEND.md Template
---
version: 1
default_model: [selected model or null]
default_seconds: null
default_size: null
---Flow 2: EXTEND.md Exists, Model Null
Ask ONLY the model question, then update EXTEND.md.
Preferences Schema
Full Schema
---
version: 1
default_model: null # veo3|veo3.1|sora-2|sora-2-pro|kling-v1-6|seedance-1.5-pro|null
default_seconds: null # "8"|"10"|"15"|null (null = model default)
default_size: null # "1280x720"|"720x1280"|"1920x1080"|null (null = model default)
---Field Reference
| Field | Type | Default | Description |
|---|---|---|---|
version | int | 1 | Schema version |
default_model | string\ | null | null |
default_seconds | string\ | null | null |
default_size | string\ | null | null |
Examples
Minimal:
---
version: 1
default_model: veo3
---Full:
---
version: 1
default_model: veo3.1
default_seconds: "8"
default_size: "1280x720"
---import path from "node:path"
import process from "node:process"
import { homedir } from "node:os"
import { access, mkdir, readFile, writeFile, unlink } from "node:fs/promises"
import type { CliArgs, ExtendConfig } from "./types"
function printUsage(): void {
console.log(`用法:
npx -y bun scripts/main.ts --prompt "一只猫在走路" --video cat.mp4
npx -y bun scripts/main.ts --promptfiles prompt.md --video out.mp4 --model veo3
npx -y bun scripts/main.ts --prompt "..." --video long.mp4 --segments 3
选项:
-p, --prompt <text> 提示词文本
--promptfiles <files...> 从文件读取提示词(多文件拼接)
--video <path> 输出视频路径(必填)
-m, --model <id> 模型 ID(默认 veo3.1)
-s, --seconds <n> 时长(秒)
--size <WxH> 尺寸(如 1280x720、16x9)
--ref <files...> 参考图片
--ref-mode reference|frames|components 参考图模式
--segments <n> 长视频段数
--segment-prompts <files...> 每段独立提示词文件
--json JSON 输出
-h, --help 显示帮助
环境变量:
TUZI_API_KEY Tuzi API 密钥(https://api.tu-zi.com)
TUZI_VIDEO_MODEL 默认视频模型(veo3.1)
TUZI_BASE_URL 自定义 Tuzi 端点
加载优先级: 命令行参数 > EXTEND.md > 环境变量 > <cwd>/.tuzi-skills/.env > ~/.tuzi-skills/.env`)
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
prompt: null,
promptFiles: [],
videoPath: null,
model: null,
seconds: null,
size: null,
referenceImages: [],
refMode: null,
segments: null,
segmentPrompts: [],
json: false,
help: false,
}
const positional: string[] = []
const takeMany = (i: number): { items: string[]; next: number } => {
const items: string[] = []
let j = i + 1
while (j < argv.length) {
const v = argv[j]!
if (v.startsWith("-")) break
items.push(v)
j++
}
return { items, next: j - 1 }
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!
if (a === "--help" || a === "-h") { out.help = true; continue }
if (a === "--json") { out.json = true; continue }
if (a === "--prompt" || a === "-p") {
const v = argv[++i]
if (!v) throw new Error(`缺少 ${a} 的值`)
out.prompt = v
continue
}
if (a === "--promptfiles") {
const { items, next } = takeMany(i)
if (items.length === 0) throw new Error("--promptfiles 缺少文件参数")
out.promptFiles.push(...items)
i = next
continue
}
if (a === "--video") {
const v = argv[++i]
if (!v) throw new Error("缺少 --video 的值")
out.videoPath = v
continue
}
if (a === "--model" || a === "-m") {
const v = argv[++i]
if (!v) throw new Error(`缺少 ${a} 的值`)
out.model = v
continue
}
if (a === "--seconds" || a === "-s") {
const v = argv[++i]
if (!v) throw new Error(`缺少 ${a} 的值`)
out.seconds = v
continue
}
if (a === "--size") {
const v = argv[++i]
if (!v) throw new Error("缺少 --size 的值")
out.size = v
continue
}
if (a === "--ref" || a === "--reference") {
const { items, next } = takeMany(i)
if (items.length === 0) throw new Error(`缺少 ${a} 的文件参数`)
out.referenceImages.push(...items)
i = next
continue
}
if (a === "--ref-mode") {
const v = argv[++i]
if (v !== "reference" && v !== "frames" && v !== "components") throw new Error(`无效的 ref-mode: ${v}`)
out.refMode = v
continue
}
if (a === "--segments") {
const v = argv[++i]
if (!v) throw new Error("缺少 --segments 的值")
out.segments = parseInt(v, 10)
if (isNaN(out.segments) || out.segments < 2) throw new Error(`无效的段数: ${v}(最少 2 段)`)
continue
}
if (a === "--segment-prompts") {
const { items, next } = takeMany(i)
if (items.length === 0) throw new Error("--segment-prompts 缺少文件参数")
out.segmentPrompts.push(...items)
i = next
continue
}
if (a.startsWith("-")) throw new Error(`未知选项: ${a}`)
positional.push(a)
}
if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
out.prompt = positional.join(" ")
}
return out
}
async function loadEnvFile(p: string): Promise<Record<string, string>> {
try {
const content = await readFile(p, "utf8")
const env: Record<string, string> = {}
for (const line of content.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const idx = trimmed.indexOf("=")
if (idx === -1) continue
const key = trimmed.slice(0, idx).trim()
let val = trimmed.slice(idx + 1).trim()
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
env[key] = val
}
return env
} catch {
return {}
}
}
async function loadEnv(): Promise<void> {
const home = homedir()
const cwd = process.cwd()
const homeEnv = await loadEnvFile(path.join(home, ".tuzi-skills", ".env"))
const cwdEnv = await loadEnvFile(path.join(cwd, ".tuzi-skills", ".env"))
for (const [k, v] of Object.entries(homeEnv)) {
if (!process.env[k]) process.env[k] = v
}
for (const [k, v] of Object.entries(cwdEnv)) {
if (!process.env[k]) process.env[k] = v
}
}
function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m)
return match ? match[1] : null
}
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {}
for (const line of yaml.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const colonIdx = trimmed.indexOf(":")
if (colonIdx === -1) continue
const key = trimmed.slice(0, colonIdx).trim()
let value = trimmed.slice(colonIdx + 1).trim()
if (value === "null" || value === "") value = "null"
if (key === "version") config.version = value === "null" ? 1 : parseInt(value, 10)
else if (key === "default_model") config.default_model = value === "null" ? null : value
else if (key === "default_seconds") config.default_seconds = value === "null" ? null : value
else if (key === "default_size") config.default_size = value === "null" ? null : value
}
return config
}
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
const home = homedir()
const cwd = process.cwd()
const paths = [
path.join(cwd, ".tuzi-skills", "tuzi-video-gen", "EXTEND.md"),
path.join(home, ".tuzi-skills", "tuzi-video-gen", "EXTEND.md"),
]
for (const p of paths) {
try {
const content = await readFile(p, "utf8")
const yaml = extractYamlFrontMatter(content)
if (!yaml) continue
return parseSimpleYaml(yaml)
} catch {
continue
}
}
return {}
}
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
return {
...args,
model: args.model ?? extend.default_model ?? null,
seconds: args.seconds ?? extend.default_seconds ?? null,
size: args.size ?? extend.default_size ?? null,
}
}
async function readPromptFromFiles(files: string[]): Promise<string> {
const parts: string[] = []
for (const f of files) {
parts.push(await readFile(f, "utf8"))
}
return parts.join("\n\n")
}
async function readPromptFromStdin(): Promise<string | null> {
if (process.stdin.isTTY) return null
try {
const t = await Bun.stdin.text()
const v = t.trim()
return v.length > 0 ? v : null
} catch {
return null
}
}
function normalizeOutputPath(p: string): string {
const full = path.resolve(p)
const ext = path.extname(full)
if (ext) return full
return `${full}.mp4`
}
async function validateReferenceImages(refs: string[]): Promise<void> {
for (const r of refs) {
try {
await access(path.resolve(r))
} catch {
throw new Error(`参考图片未找到: ${path.resolve(r)}`)
}
}
}
async function checkFfmpeg(): Promise<boolean> {
try {
const proc = Bun.spawn(["ffmpeg", "-version"], { stdout: "pipe", stderr: "pipe" })
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
async function extractLastFrame(videoPath: string, outputPath: string): Promise<void> {
const proc = Bun.spawn(
["ffmpeg", "-y", "-sseof", "-0.1", "-i", videoPath, "-frames:v", "1", outputPath],
{ stdout: "pipe", stderr: "pipe" }
)
const exitCode = await proc.exited
if (exitCode !== 0) {
const err = await new Response(proc.stderr).text()
throw new Error(`ffmpeg 提取尾帧失败: ${err}`)
}
}
async function concatVideos(segments: string[], outputPath: string): Promise<void> {
const tmpDir = path.dirname(outputPath)
const listFile = path.join(tmpDir, ".concat-list.txt")
const lines = segments.map((s) => `file '${s}'`).join("\n")
await writeFile(listFile, lines)
const proc = Bun.spawn(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputPath],
{ stdout: "pipe", stderr: "pipe" }
)
const exitCode = await proc.exited
await unlink(listFile).catch(() => {})
if (exitCode !== 0) {
const err = await new Response(proc.stderr).text()
throw new Error(`ffmpeg 合并失败: ${err}`)
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printUsage()
return
}
await loadEnv()
const extendConfig = await loadExtendConfig()
const mergedArgs = mergeConfig(args, extendConfig)
let prompt: string | null = mergedArgs.prompt
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles)
if (!prompt) prompt = await readPromptFromStdin()
const hasSegmentPrompts = mergedArgs.segmentPrompts.length > 0
if (!prompt && !hasSegmentPrompts) {
console.error("错误: 提示词不能为空(使用 --prompt、--promptfiles 或 --segment-prompts)")
printUsage()
process.exitCode = 1
return
}
if (!mergedArgs.videoPath) {
console.error("错误: --video 参数必填")
printUsage()
process.exitCode = 1
return
}
if (mergedArgs.referenceImages.length > 0) {
await validateReferenceImages(mergedArgs.referenceImages)
}
const { generateVideo } = await import("./providers/tuzi")
const { getDefaultModel } = await import("./providers/tuzi")
const model = mergedArgs.model || getDefaultModel()
const outputPath = normalizeOutputPath(mergedArgs.videoPath)
if (mergedArgs.segments && mergedArgs.segments >= 2) {
const hasFfmpeg = await checkFfmpeg()
if (!hasFfmpeg) {
console.error("错误: 长视频模式需要 ffmpeg。请安装 ffmpeg 后重试。\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg")
process.exitCode = 1
return
}
const segDir = path.join(path.dirname(outputPath), "segments")
await mkdir(segDir, { recursive: true })
const segPaths: string[] = []
const n = mergedArgs.segments
for (let i = 0; i < n; i++) {
let segPrompt: string | null = null
if (mergedArgs.segmentPrompts[i]) {
segPrompt = await readFile(mergedArgs.segmentPrompts[i]!, "utf8")
} else {
segPrompt = prompt
}
if (!segPrompt) {
console.error(`错误: 第 ${i + 1} 段缺少提示词(需要 --prompt 或对应的 --segment-prompts)`)
process.exitCode = 1
return
}
const segArgs: CliArgs = { ...mergedArgs }
if (i > 0 && segPaths.length > 0) {
const lastFramePath = path.join(segDir, `frame-${i - 1}.png`)
try {
await extractLastFrame(segPaths[i - 1]!, lastFramePath)
segArgs.referenceImages = [lastFramePath]
segArgs.refMode = segArgs.refMode || "frames"
} catch (e) {
console.error(`警告: 提取第 ${i} 段尾帧失败,跳过首帧参考: ${e instanceof Error ? e.message : e}`)
}
}
const segPath = path.join(segDir, `seg-${String(i + 1).padStart(2, "0")}.mp4`)
console.log(`\n生成第 ${i + 1}/${n} 段...`)
let data: Uint8Array
let retried = false
while (true) {
try {
data = await generateVideo(segPrompt, model, segArgs)
break
} catch (e) {
if (!retried) {
retried = true
console.error("生成失败,正在重试...")
continue
}
throw e
}
}
await writeFile(segPath, data)
segPaths.push(segPath)
console.log(`第 ${i + 1}/${n} 段完成`)
}
console.log("\n正在合并视频...")
const dir = path.dirname(outputPath)
await mkdir(dir, { recursive: true })
await concatVideos(segPaths, outputPath)
console.log(`合并完成。分段视频保留在: ${segDir}`)
} else {
if (!prompt) {
console.error("错误: 单视频模式需要 --prompt 或 --promptfiles")
process.exitCode = 1
return
}
let data: Uint8Array
let retried = false
while (true) {
try {
data = await generateVideo(prompt, model, mergedArgs)
break
} catch (e) {
if (!retried) {
retried = true
console.error("生成失败,正在重试...")
continue
}
throw e
}
}
const dir = path.dirname(outputPath)
await mkdir(dir, { recursive: true })
await writeFile(outputPath, data)
}
if (mergedArgs.json) {
console.log(JSON.stringify({ savedVideo: outputPath, model, prompt: prompt.slice(0, 200) }, null, 2))
} else {
console.log(outputPath)
}
}
main().catch((e) => {
const msg = e instanceof Error ? e.message : String(e)
console.error(msg)
process.exit(1)
})
import { readFile, unlink } from "node:fs/promises"
import { tmpdir } from "node:os"
import { spawn } from "node:child_process"
import path from "node:path"
import type { CliArgs } from "../types"
const DEFAULT_MODEL = "veo3.1"
const POLL_INTERVAL_MS = 5000
const MAX_POLL_MS = 90 * 60 * 1000
const BACKOFF_MULTIPLIER = 1.5
const MAX_BACKOFF_MS = 60000
export function getDefaultModel(): string {
return process.env.TUZI_VIDEO_MODEL || DEFAULT_MODEL
}
function getApiKey(): string | null {
return process.env.TUZI_API_KEY || null
}
function getBaseUrl(): string {
const base = process.env.TUZI_BASE_URL || "https://api.tu-zi.com"
return base.replace(/\/+$/g, "").replace(/\/v1\/?$/, "")
}
type SubmitResponse = { id: string; status: string; error?: unknown }
type PollResponse = { id: string; status: string; progress?: number; video_url?: string; url?: string; error?: unknown }
function parseError(error: unknown): string {
if (!error) return "未知错误"
if (typeof error === "string") return error
if (typeof error === "object" && error !== null) {
const e = error as Record<string, unknown>
if (typeof e.message === "string") return e.message
}
return String(error)
}
function isNetworkError(e: unknown): boolean {
const msg = e instanceof Error ? e.message : String(e)
const networkMarkers = ["fetch failed", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "network", "socket"]
return networkMarkers.some((m) => msg.toLowerCase().includes(m.toLowerCase()))
}
const MAX_REF_IMAGE_BYTES = 1024 * 1024
function mimeFromExt(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"
if (ext === ".webp") return "image/webp"
if (ext === ".gif") return "image/gif"
if (ext === ".bmp") return "image/bmp"
return "image/png"
}
function extFromMime(mime: string): string {
if (mime === "image/jpeg") return ".jpg"
if (mime === "image/webp") return ".webp"
if (mime === "image/gif") return ".gif"
if (mime === "image/bmp") return ".bmp"
return ".png"
}
function runCmd(cmd: string, args: string[]): Promise<{ code: number; stderr: string }> {
return new Promise((res) => {
const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] })
let stderr = ""
proc.stderr?.on("data", (d: Buffer) => (stderr += d.toString()))
proc.on("close", (code) => res({ code: code ?? 1, stderr }))
proc.on("error", (e) => res({ code: 1, stderr: e.message }))
})
}
async function compressImage(filePath: string): Promise<{ bytes: Uint8Array; mime: string }> {
const tmp = path.join(tmpdir(), `tuzi-ref-${Date.now()}.jpg`)
try {
if (process.platform === "darwin") {
const { code } = await runCmd("sips", ["-s", "format", "jpeg", "-s", "formatOptions", "70", filePath, "--out", tmp])
if (code === 0) {
const compressed = await readFile(tmp)
return { bytes: new Uint8Array(compressed), mime: "image/jpeg" }
}
}
const { code } = await runCmd("convert", [filePath, "-quality", "70", tmp])
if (code === 0) {
const compressed = await readFile(tmp)
return { bytes: new Uint8Array(compressed), mime: "image/jpeg" }
}
const original = await readFile(filePath)
return { bytes: new Uint8Array(original), mime: mimeFromExt(filePath) }
} finally {
await unlink(tmp).catch(() => {})
}
}
async function readRefImage(filePath: string): Promise<{ blob: Blob; filename: string }> {
const bytes = await readFile(filePath)
const mime = mimeFromExt(filePath)
if (bytes.length > MAX_REF_IMAGE_BYTES && (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp" || mime === "image/bmp")) {
const { bytes: compressed, mime: cMime } = await compressImage(filePath)
if (compressed.length < bytes.length) {
const ext = extFromMime(cMime)
console.log(`参考图 ${path.basename(filePath)} 已压缩: ${bytes.length} → ${compressed.length} bytes`)
return { blob: new Blob([compressed], { type: cMime }), filename: `reference${ext}` }
}
}
const ext = extFromMime(mime)
return { blob: new Blob([bytes], { type: mime }), filename: `reference${ext}` }
}
async function download(url: string): Promise<Uint8Array> {
const res = await fetch(url)
if (!res.ok) throw new Error(`视频下载失败: ${res.status}`)
return new Uint8Array(await res.arrayBuffer())
}
export async function generateVideo(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey()
if (!apiKey) throw new Error("TUZI_API_KEY 未配置。请前往 https://api.tu-zi.com/token 获取(视频教程:https://www.bilibili.com/video/BV1k4PqzPEKz/)")
const baseURL = getBaseUrl()
const form = new FormData()
form.append("model", model)
form.append("prompt", prompt)
if (args.seconds) form.append("seconds", args.seconds)
if (args.size) form.append("size", args.size)
if (args.referenceImages.length > 0) {
const mode = args.refMode || "reference"
for (let i = 0; i < args.referenceImages.length; i++) {
const { blob, filename } = await readRefImage(args.referenceImages[i]!)
form.append("input_reference", blob, `${i + 1}-${filename}`)
}
form.append("ref_mode", mode)
}
console.log(`正在提交视频生成任务 (${model})...`)
const submitRes = await fetch(`${baseURL}/v1/videos`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
})
if (!submitRes.ok) {
const err = await submitRes.text()
throw new Error(`Tuzi API 提交错误 (${submitRes.status}): ${err}`)
}
const submitData = (await submitRes.json()) as SubmitResponse
if (submitData.status === "failed") {
throw new Error(parseError(submitData.error))
}
const taskId = submitData.id
if (!taskId) throw new Error("Tuzi API 未返回任务 ID")
console.log(`任务已提交 (id: ${taskId}),正在轮询结果...`)
const startTime = Date.now()
let backoff = POLL_INTERVAL_MS
while (Date.now() - startTime < MAX_POLL_MS) {
await new Promise((r) => setTimeout(r, backoff))
let pollRes: Response
try {
pollRes = await fetch(`${baseURL}/v1/videos/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
} catch (e) {
if (isNetworkError(e)) {
backoff = Math.min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS)
console.error(`网络错误,${Math.round(backoff / 1000)}s 后重试...`)
continue
}
throw e
}
backoff = POLL_INTERVAL_MS
if (!pollRes.ok) {
const err = await pollRes.text()
throw new Error(`Tuzi 轮询错误 (${pollRes.status}): ${err}`)
}
const status = (await pollRes.json()) as PollResponse
const elapsed = Math.round((Date.now() - startTime) / 1000)
if (elapsed % 30 < 6) {
console.log(`轮询中... 状态=${status.status}, 进度=${status.progress ?? 0}, 已用时=${elapsed}s`)
}
if (status.status === "completed") {
const url = status.video_url || status.url
if (!url) throw new Error("Tuzi API 未返回视频 URL")
console.log("视频生成完成。")
return download(url)
}
if (status.status === "failed") {
throw new Error(parseError(status.error))
}
}
throw new Error(`视频生成超时,已等待 ${MAX_POLL_MS / 1000 / 60} 分钟`)
}
export type CliArgs = {
prompt: string | null
promptFiles: string[]
videoPath: string | null
model: string | null
seconds: string | null
size: string | null
referenceImages: string[]
refMode: "reference" | "frames" | "components" | null
segments: number | null
segmentPrompts: string[]
json: boolean
help: boolean
}
export type ExtendConfig = {
version: number
default_model: string | null
default_seconds: string | null
default_size: string | null
}