跳到正文
跟我做
自媒体自动化7 分钟FFmpeg

AI 短剧先做 15 秒:3 个镜头验 5 个穿帮点

先别做 1 分钟。用 3 个镜头、15 秒,检查脸、口型、空间、节奏和 AI 标识;过不了就换题材。附本地 ffmpeg 拼片脚本和止损表。

约 7 分钟
本页目录
  1. 这篇解决什么问题
  2. 先做 15 秒,不要做 1 分钟
  3. 3 个镜头怎么截
  4. 5 个验收点
  5. 止损规则
  6. 止损表
  7. 本地拼片
  8. 装 ffmpeg
  9. 放素材
  10. 写核心模块
  11. 写命令入口
  12. 跑一次
  13. 配音和字幕
  14. 合规:两道硬门槛
  15. 哪些是实测,哪些是引用
  16. 常见坑
  17. 1. 字幕太大或带黑块
  18. 2. 配音太轻
  19. 3. 音画不同步
  20. 4. 横屏素材变黑边
  21. 5. Windows 路径带冒号
  22. 下一步读什么

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说「退订」就删掉。详见隐私政策。

同方向继续

全部教程