AI 短剧先做 15 秒:3 个镜头验 5 个穿帮点
先别做 1 分钟。用 3 个镜头、15 秒,检查脸、口型、空间、节奏和 AI 标识;过不了就换题材。附本地 ffmpeg 拼片脚本和止损表。
本页目录
30 秒速查
- 先做 15 秒,不铺 1 分钟
- 3 镜:正面中景、侧面/反打、说话特写
- 每镜跑 3 次,可用少于 2/3 就停
- 本地拼片:
node resources/ai-drama-test/assemble.mjs input/- 发布前选 6 类标签;漫剧先看备案
这篇解决什么问题
这篇不教你怎么生成画面。它解决另一个问题:什么时候该停。
AI 短剧最容易亏钱的地方,不是不会写提示词,是花了几天做 1 分钟,才发现角色换人、嘴不动、正反打换边。那时候重做,前面的额度、时间和情绪都沉没了。
做法很土:先做 15 秒,3 个镜头,按 5 个点验收。过不了就换题材或换工具,别往下铺。
先做 15 秒,不要做 1 分钟
1 分钟短剧通常要 12–20 个镜头。每个镜头抽 3–5 次,就是 40–100 次生成;免费额度按天清零,一天根本铺不完。
15 秒只要 3 个镜头,正好覆盖最容易穿帮的三类画面:
- 正面中景:看脸和服装;
- 侧面/反打:看空间关系;
- 说话特写:看口型。
3 个镜头怎么截
| 镜头 | 时长 | 画面要求 | 看什么 |
|---|---|---|---|
| 正面中景 | 5 秒 | 人物胸口以上,正对镜头,背景干净 | 脸型、发际线、领口、衣服颜色 |
| 侧面/反打 | 5 秒 | 同场景换一个角度,人物左右关系清楚 | 谁在左、谁在右,背景和视线有没有跳 |
| 说话特写 | 5 秒 | 脸部占画面 1/3 以上,嘴部清楚 | 嘴有没有动,声音和口型对不对得上 |
三个镜头要连得起来:同一场景、同一套衣服、同一段时间。
5 个验收点
| 验收点 | 怎么测 | 不过怎么办 |
|---|---|---|
| 脸和服装一致 | 3 镜暂停对比:脸型、发际线、领口、衣服颜色 | 换角色参考图,或换工具;不要靠提示词硬修 |
| 口型 | 特写逐字看,声音出来后嘴有没有同步 | 改旁白,不做口型;或只保留正面特写 |
| 空间关系 | 正反打里谁在左、谁在右,背景和视线是否连续 | 补场景反打参考;还不行就改单机位 |
| 镜头节奏 | 15 秒能不能讲清“谁要什么、遇到什么、留下什么” | 像 PPT 就改分镜,别扩到 1 分钟 |
| AI 标识 | 发布页能不能选“AI 生成/虚构演绎” | 标不了就停,先查平台规则和备案 |
止损规则
同一角色,每个镜头连跑 3 次,共 9 段。每个验收点至少 2/3 可用,才继续。
不是“整片看起来还行”,是每个点单独算:
- 3 次里 2 次脸不崩,才算脸过关;
- 3 次里 2 次口型对得上,才算口型过关;
- 任何一项低于 2/3,就停,不进入 1 分钟。
止损表
| 检查项 | 继续 | 停 / 换 | 先改什么 |
|---|---|---|---|
| 脸和服装 | 3 次里 ≥2 次同一个人 | 3 次里 2 次换人 | 换角色参考图或工具 |
| 口型 | 特写能对上,误差不明显 | 嘴不动、延迟、侧脸穿帮 | 改旁白,不做口型 |
| 空间 | 左右、背景、视线连续 | 换边、背景跳、人物漂移 | 补反打参考,改单机位 |
| 节奏 | 15 秒能讲清一件事 | 像 PPT、看不懂 | 改分镜,不扩时长 |
| 标识 | 能加 AI/虚构标签 | 不知道怎么标 | 停,先查规则/备案 |
本地拼片
这一步是本机实测的:3 段素材统一成 5 秒、1080×1920、30fps,拼成 15 秒,烧字幕,混配音和转场音效。
装 ffmpeg
ffmpeg -version
macOS 用 brew install ffmpeg,Windows 用 winget install Gyan.FFmpeg。PATH 报错看ffmpeg 排错。
放素材
input/
├── shot-1.mp4
├── shot-2.mp4
├── shot-3.mp4
├── subs.srt
└── voice.mp3
whoosh.wav 可选;不放的话,脚本会用 ffmpeg 生成 0.35 秒的粉噪声,在 0.2s、4.8s、9.8s 各放一次。
写核心模块
保存为 resources/ai-drama-test/core.mjs:
import { execFile } from "node:child_process";
import { access, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const run = promisify(execFile);
export const WIDTH = 1080;
export const HEIGHT = 1920;
export const FPS = 30;
export const SHOT_SECONDS = 5;
export const TOTAL_SECONDS = 15;
export function resolveFfmpeg(explicit) {
return explicit || process.env.FFMPEG_PATH || "ffmpeg";
}
export function buildNormalizeArgs({ input, output }) {
return [
"-y", "-stream_loop", "-1", "-i", input, "-t", String(SHOT_SECONDS),
"-vf", `scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
`pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black,fps=${FPS}`,
"-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p", output,
];
}
export function buildWhooshArgs({ output }) {
return [
"-y", "-f", "lavfi", "-i", "anoisesrc=color=pink:duration=0.35:sample_rate=44100",
"-af", "highpass=f=700,lowpass=f=5000,afade=t=out:st=0.15:d=0.2,volume=0.22",
"-c:a", "pcm_s16le", output,
];
}
export function buildAssembleArgs({ list, srt, voice, whoosh, output }) {
const subtitle = `subtitles='${srt.replace(/\\/g, "/")}':force_style='FontName=Noto Sans CJK SC,FontSize=8,Outline=0,Shadow=1,MarginV=40,Alignment=2'`;
const filter = [
`[0:v]${subtitle}[v]`,
`[1:a]apad,atrim=0:${TOTAL_SECONDS},volume=1.0[voice]`,
"[2:a]asplit=3[s1][s2][s3]",
"[s1]adelay=200|200[a1]",
"[s2]adelay=4800|4800[a2]",
"[s3]adelay=9800|9800[a3]",
`[voice][a1][a2][a3]amix=inputs=4:duration=first:weights='1 0.22 0.22 0.22'[mixed]`,
`[mixed]volume=1.6[a]`,
].join(";");
return [
"-y", "-f", "concat", "-safe", "0", "-i", list,
"-i", voice, "-i", whoosh,
"-filter_complex", filter,
"-map", "[v]", "-map", "[a]", "-t", String(TOTAL_SECONDS),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", output,
];
}
export async function probeDuration(file, { ffmpeg } = {}) {
let info = "";
try {
await run(resolveFfmpeg(ffmpeg), ["-hide_banner", "-i", file]);
} catch (error) {
info = error.stderr ?? "";
}
const match = /Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/.exec(info);
if (!match) throw new Error(`读不出时长:${file}`);
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
}
export async function assemble({ shots, srt, voice, whoosh, output, ffmpeg } = {}) {
if (shots.length !== 3) throw new Error("必须正好 3 个镜头。");
await Promise.all([...shots, srt, voice].map((file) => access(file)));
await mkdir(path.dirname(output), { recursive: true });
const tmp = await mkdtemp(path.join(tmpdir(), "ai-drama-"));
try {
const clips = [];
for (const [index, shot] of shots.entries()) {
const clip = path.join(tmp, `norm-${index + 1}.mp4`);
await run(resolveFfmpeg(ffmpeg), buildNormalizeArgs({ input: shot, output: clip }));
clips.push(clip);
}
const list = path.join(tmp, "list.txt");
await writeFile(list, clips.map((clip) => `file '${clip}'`).join("\n"));
const sfx = whoosh ?? path.join(tmp, "whoosh.wav");
if (!whoosh) {
await run(resolveFfmpeg(ffmpeg), buildWhooshArgs({ output: sfx }));
}
await run(
resolveFfmpeg(ffmpeg),
buildAssembleArgs({ list, srt, voice, whoosh: sfx, output }),
);
const seconds = await probeDuration(output, { ffmpeg });
return { output, seconds, bytes: (await stat(output)).size };
} finally {
await rm(tmp, { recursive: true, force: true });
}
}
写命令入口
保存为 resources/ai-drama-test/assemble.mjs:
import { existsSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { assemble, resolveFfmpeg } from "./core.mjs";
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const { values, positionals } = parseArgs({
options: {
out: { type: "string", default: "out/15s.mp4" },
ffmpeg: { type: "string" },
},
allowPositionals: true,
});
const inputDir = positionals[0];
if (!inputDir) {
console.error("用法:node resources/ai-drama-test/assemble.mjs input/");
process.exitCode = 1;
} else {
try {
const base = path.resolve(inputDir);
const shots = [1, 2, 3].map((number) =>
path.join(base, `shot-${number}.mp4`),
);
const whooshPath = path.join(base, "whoosh.wav");
const result = await assemble({
shots,
srt: path.join(base, "subs.srt"),
voice: path.join(base, "voice.mp3"),
whoosh: existsSync(whooshPath) ? whooshPath : undefined,
output: path.resolve(values.out),
ffmpeg: resolveFfmpeg(values.ffmpeg),
});
console.log(`输出:${path.relative(process.cwd(), result.output)}`);
console.log(`时长:${result.seconds.toFixed(2)} 秒`);
console.log(`体积:${(result.bytes / 1024 / 1024).toFixed(1)} MB`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
}
跑一次
node resources/ai-drama-test/assemble.mjs input/
真实输出:
输出:out/15s.mp4
时长:15.04 秒
体积:0.3 MB
我验证时用的是 3 段纯色占位片,不是 AI 生成画面;它们只证明拼片、字幕、混音这条链路能跑。真实项目把 shot-1/2/3.mp4 换成你的 3 段素材即可。
配音和字幕
配音用 Edge TTS,免费,中文音色够用:
edge-tts --text "先看正面中景:脸和衣服,是不是同一个人。再看侧面反打:两个人的位置,有没有突然换边。最后看说话特写:嘴在动,声音和口型对得上吗?" --voice zh-CN-YunxiNeural --write-media input/voice.mp3
我实测这段 13.99 秒,82 KB;脚本会用 apad,atrim=0:15 补齐或截到 15 秒。
字幕 subs.srt 按时长分三段,每段两行以内:
1
00:00:00,400 --> 00:00:04,700
先看正面中景:
脸和衣服,是不是同一个人。
2
00:00:05,000 --> 00:00:09,700
再看侧面反打:
两个人的位置,有没有突然换边。
3
00:00:10,000 --> 00:00:14,700
最后看说话特写:
嘴在动,声音和口型对得上吗?
字幕字号用了 FontSize=8,因为 libass 默认按 384×288 的画布缩放;写 20 会变成满屏大字。觉得小就调到 9–10,别直接写 20。
合规:两道硬门槛
第一道:发布时选标签。 2026-05 起,短视频发布必须从 6 类必选标签中选一项才能发:虚构演绎、AI 生成、营销信息、转载、个人观点、无需标注。(人民日报)
第二道:AI 漫剧备案。 2026-04-01 起,AI 漫剧纳入微短剧备案;投资 100 万以下的一般题材,由平台自审后统一报省级备案,AI 内容要在片头或显著位置标注。(流媒体网)
15 秒测试片也要选标签;如果准备做成连续剧集,不要用“短片”名义绕备案。
哪些是实测,哪些是引用
| 部分 | 状态 |
|---|---|
| 3 镜拼 15 秒、字幕、音效、ffmpeg | 本机实测(Linux,ffmpeg 2018 static) |
| Edge TTS 中文配音 | 本机实测(13.99 秒) |
| 3 段测试素材 | 纯色占位片,不是 AI 生成 |
| 免费额度、生成工具 | 按公开资料整理,未逐镜实操 |
| 失败案例、成本 | 引用公开报道 |
| 6 类标签、备案 | 引用官方/平台公告 |
这篇没有假装我们跑通过完整 AI 短剧:没有 NVIDIA 显卡,也没买付费额度。云端生成那几步,等有真实素材再补实测。
常见坑
1. 字幕太大或带黑块
FontSize 不是像素。libass 按 384×288 缩放,写 20 会满屏。用 8–10,Outline=0、Shadow=1;字体没有就换成系统里的中文字体。
2. 配音太轻
amix 会把多路音频压小。脚本用 weights 控制音效,再整体 volume=1.6;还轻就调到 2.0,注意别削波。
3. 音画不同步
配音 13.99 秒、视频 15 秒,脚本会补静音。如果配音超过 15 秒,atrim 会截断;先把文案压到 13 秒以内。
4. 横屏素材变黑边
脚本用 pad 补黑边,不裁画面。要填满 9:16,把 force_original_aspect_ratio=decrease 改成 increase,再加 crop=1080:1920,但会裁掉边缘。
5. Windows 路径带冒号
subtitles 滤镜对 C: 敏感。把 input/ 放在当前目录,用相对路径;或先确认你的 ffmpeg 版本支持转义。
下一步读什么
3 镜过了,再去做多平台适配:一条视频三个平台批量改尺寸、抽封面。
只要声音或 GIF,看视频转音频或 GIF;这条路线收在短视频制作合集里。
常见问题
为什么只做 15 秒,不做 1 分钟?
15 秒就能暴露脸、口型、空间、节奏四个问题。先花半天验证,比花两天做 1 分钟再发现角色换人便宜。
3 个镜头从哪来?
用免费额度生成,或自己拍。本文的脚本只负责拼片,不生成画面;我验证时用的是 3 段纯色占位片。
没有 ffmpeg 能跑吗?
不能。先装 ffmpeg 并确认 `ffmpeg -version` 能跑;PATH 报错看站内 ffmpeg 排错那篇。
15 秒测试片能直接发吗?
能发,但要按平台选 AI 生成/虚构演绎标签;如果准备做连续剧集,先确认要不要走微短剧备案。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。