Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,21 @@ python scripts/orchestrator.py \

CI 也会跑这一套 dry-run,防止入口契约、QR registry、导入路由或 self-report no-write 行为漂移。

### Step -0.5:旁白改写和 AI 味检查

短视频脚本先过一遍“活人感”检查,再进入分镜。规则从 `md2wechat` 的
`khazix-writer` 改写方法论吸收而来,但已经改成 md2video 的口播场景:
少写报告腔,多写现场动作、具体工具名、运行证据和真人复盘。

```bash
python scripts/lint_narration_style.py \
--input examples/ai_workflow_video_script.md
```

硬规则会拦住“综上所述”“这意味着”“在当今……”这类套话、空泛工具名、
超长段落和不利于 TTS 的标点。L2 只给风格提醒,例如口语化不足、缺少具体锚点、
缺少疑问句转向。`scripts/preflight.py` 已自动接入这个检查。

### Step 0:分镜拆解

**规则驱动,无需改代码。** 将文章输入 `storyboard_ai.py`,自动输出:
Expand Down
3 changes: 3 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ python scripts/preflight.py --input examples/example_article.md --skip-command-c
```
article.md
lint_narration_style → 旁白活人感 / 反套话检查
storyboard_ai → shots.json + segments_hint.json + prompts.json
segment_tts → segments.json(精确时长)
Expand All @@ -180,6 +182,7 @@ harness → compliance_report.json

| 组件 | 核心原则 |
|------|---------|
| `lint_narration_style` | 吸收 md2wechat/khazix-writer 的改写方法论,拦截报告腔、套话、空泛工具名和 TTS 不友好的标点 |
| `segment_tts` | 按语义切分,独立生成,ffprobe 精确测时长 |
| `timeline_mapper` | Single Source of Truth,程序化对齐,L1 硬阻塞校验,Clip 模型支持 fade/transition |
| `concat_engine` | **双路径策略**:无特效→`-c copy` 快速路径;有特效→filter_complex (xfade) + Python numpy 音频混合。`acrossfade` 无 `offset` 参数已被废弃,音频用 `adelay`+`amix` 或 Python 逐段叠加 |
Expand Down
18 changes: 11 additions & 7 deletions examples/ai_workflow_video_script.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# 三分钟建立可复用的 AI 工作流
# 我现在做视频脚本,会先看一眼证据

很多人用 AI 写东西,最大的问题不是不会提问,而是每次都从零开始。今天这条短视频,只讲一个方法,把一次性的聊天,变成可以复用的工作流
事情是这样的。前两天我又让 Codex 帮我整理一段交付,结果发现问题不在模型,而在我每次都临场发挥

先看一个对比。临时提问通常要花 10 分钟,还经常漏掉检查步骤;流程化之后,准备时间可以压到 2 分钟,复用率从 0 提升到 80%。差别不在模型,而在你有没有把输入、规则、验证和交付固定下来
说真的,临场提问很爽,但复盘的时候很痛苦。十分钟过去了,我还在补背景、补约束、补检查项。我当时就在想,那我到底漏了什么?下一次换个任务,又从头再来

第一,先写清楚输入是什么;第二,写清楚输出长什么样;第三,把失败时必须检查的步骤列出来;第四,把验证命令留在文档里。只要这四件事稳定,AI 就不再只是聊天窗口,而是一个可审计的执行伙伴
后来我给自己定了一个笨办法。第一,输入是什么。第二,结果长什么样。第三,失败了先查哪几处。第四,最后用什么证据算完成

2026年6月8日,我给自己的工作流加了一条新规:每次交付前必须留下运行证明。比如日志、manifest、检查报告和最终文件路径。没有证据,就不能说完成
很笨,但有效

我给团队留下一句金句:好提示词不是让 AI 显得聪明,而是让结果可以被复核、被复用、被改进
你想想看,这四行一固定,事情就变了。Codex 不再只是在聊天框里接话,它会沿着流程做事,留下日志、清单、检查报告和最终文件路径

如果你也想把零散经验变成稳定流程,先收藏这条视频。评论区告诉我你的工作流卡在哪一步,我会继续拆给你看。
2026 年 6 月 8 日,我又加了一条规矩。没有运行证明,就别说交付完成。听着有点较真,但这玩意真的救命。

我现在越来越觉得,好提示词不是把模型哄得更聪明,而是让结果经得起回头看。能复核,能复用,也能被下一次改掉。

如果你也经常把一堆经验散落在聊天记录里,先别急着写更长的 prompt。先写那四行。你会马上知道,自己到底卡在哪一步。
141 changes: 141 additions & 0 deletions rules/narration_style_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
{
"_source": "Adapted from md2wechat references/khazix-writer, derived from KKKKhazix/Khazix-Skills under the MIT License.",
"_description": "Narration style rules for md2video. L1 rules block pipeline readiness; L2 rules warn about script voice and rhythm.",
"version": "1.0.0",
"l1_banned_words": [
"说白了",
"意味着什么",
"这意味着",
"本质上",
"换句话说",
"不可否认",
"综上所述",
"总的来说",
"值得注意的是",
"不难发现",
"让我们来看看",
"接下来让我们",
"在当今",
"随着技术"
],
"l1_banned_words_replacements": {
"说白了": "坦率的讲 / 其实就是",
"意味着什么": "那结果会怎样",
"这意味着": "所以呢",
"本质上": "说到底 / 其实",
"换句话说": "你想想看 / 也就是说",
"不可否认": "直接正面陈述",
"综上所述": "用具体回扣句替代",
"总的来说": "用具体回扣句替代",
"值得注意的是": "删掉,直接说",
"不难发现": "删掉,直接说",
"让我们来看看": "删掉,直接进入画面",
"接下来让我们": "删掉,直接进入画面",
"在当今": "换成具体时间、场景或动作",
"随着技术": "换成具体工具或事件"
},
"l1_banned_punctuation": [
{
"char": "——",
"reason": "破折号会让 TTS 节奏僵硬,改用逗号或句号",
"replacement": ","
},
{
"char": "“",
"reason": "中文双引号改用「」或直接不加",
"replacement": "「"
},
{
"char": "”",
"reason": "中文双引号改用「」或直接不加",
"replacement": "」"
}
],
"l2_banned_punctuation": [
{
"char": ":",
"reason": "中文冒号偏报告腔,口播脚本优先用逗号",
"replacement": ","
}
],
"l1_banned_structures": [
"首先.*其次.*最后",
"让我们来看看",
"接下来让我们",
"在当今.*时代",
"随着.*不断.*发展",
"随着.*快速.*发展",
"本文将"
],
"l1_banned_vague_tools": [
"AI工具",
"某个模型",
"相关技术",
"某AI",
"一些工具",
"相关工具"
],
"l1_max_paragraph_chars": 220,
"l2_no_go_openings": [
"在当今.*时代",
"随着.*不断.*发展",
"随着.*快速.*发展",
"众所周知",
"不可否认的是"
],
"l2_min_colloquial_expressions": 2,
"l2_colloquial_expressions": [
"坦率的讲",
"说真的",
"我是真的觉得",
"反正我觉得",
"怎么说呢",
"其实吧",
"你想想看",
"我跟你说",
"回到.*这块",
"这块需要注意",
"顺着上面的",
"我有时候觉得",
"我一直觉得",
"我自己的感受是",
"我自己也还在摸索",
"说实话",
"我也不知道",
"我当时就",
"想想就觉得",
"太离谱了",
"这玩意",
"不是哥们",
"我寻思",
"真的就是",
"你敢信",
"一时间",
"听着有点",
"别急着"
],
"l2_min_concrete_anchors": 2,
"l2_concrete_anchor_patterns": [
"\\d{4}\\s*年",
"\\d+\\s*分钟",
"Codex",
"ChatGPT",
"Claude",
"Seedance",
"即梦",
"日志",
"清单",
"检查报告",
"文件路径",
"我"
],
"l2_min_question_marks": 1,
"l2_sentence_variance_threshold": 6,
"l2_max_consecutive_similar_length": 4,
"l3_manual_checks": [
"开头是否从具体动作或具体现场切入,而不是价值观口号",
"每段是否有画面、动作、工具名或证据,不只是在讲抽象道理",
"是否有一句不完美但真实的判断,让口播像真人复盘",
"结尾是否给一个马上能做的小动作,而不是泛泛号召"
]
}
50 changes: 38 additions & 12 deletions scripts/generate_ai_workflow_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ def save_json(path: Path, data: Any) -> None:
json.dump(data, f, ensure_ascii=False, indent=2)


def reset_generated_subdirs(run_dir: Path) -> None:
"""Remove reproducible per-run media dirs before rebuilding a demo video."""
for name in ("scenes", "dreamina_downloads", "narration_segments"):
target = run_dir / name
if target.exists():
shutil.rmtree(target)


def parse_json_maybe(text: str) -> dict[str, Any]:
text = text.strip()
if not text:
Expand Down Expand Up @@ -366,16 +374,17 @@ def font(size: int) -> ImageFont.ImageFont:
def wrap_text(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.ImageFont, max_width: int) -> list[str]:
lines: list[str] = []
current = ""
for char in text:
candidate = current + char
tokens = re.findall(r"[A-Za-z0-9_+.-]+|\s+|.", text)
for token in tokens:
candidate = current + token
bbox = draw.textbbox((0, 0), candidate, font=face)
if current and bbox[2] - bbox[0] > max_width:
lines.append(current)
current = char
lines.append(current.rstrip())
current = token.lstrip()
else:
current = candidate
if current:
lines.append(current)
lines.append(current.rstrip())
return lines


Expand Down Expand Up @@ -448,6 +457,20 @@ def draw_footer(draw: ImageDraw.ImageDraw, segment_type: str) -> None:
draw.text((96, HEIGHT - 132), label, fill=(28, 48, 58), font=face)


def narrative_title(text: str) -> str:
if "Codex" in text or "模型" in text:
return "不是模型的问题"
if "笨办法" in text or "四行" in text or "第一" in text:
return "四行笨办法"
if "运行证明" in text or "证据" in text:
return "没有证据,就别说完成"
if "提示词" in text:
return "回头看结果"
if "prompt" in text:
return "先写那四行"
return "从临场发挥到稳定流程"


def render_hook(segment: dict[str, Any], output_path: Path) -> None:
img, draw = base_image((10, 25, 33))
accent = (77, 191, 172)
Expand All @@ -457,10 +480,10 @@ def render_hook(segment: dict[str, Any], output_path: Path) -> None:
draw.line([(80, y), (WIDTH - 80, y - 80)], fill=color, width=6)
draw.rounded_rectangle([80, 220, WIDTH - 80, 1180], radius=36, fill=(17, 43, 52), outline=accent, width=3)
draw.text((116, 280), "md2video", fill=accent, font=font(42))
title = "三分钟建立可复用的 AI 工作流"
title = segment["text"]
lines = wrap_text(draw, title, font(86), WIDTH - 220)
draw_centered_lines(draw, lines, 470, font(86), (245, 249, 250), 26)
subtitle_lines = wrap_text(draw, "把一次性的聊天,变成可复核、可复用、可改进的流程。", font(48), WIDTH - 260)
subtitle_lines = wrap_text(draw, "先把临场发挥,改成能回头看的流程。", font(48), WIDTH - 260)
draw_centered_lines(draw, subtitle_lines, 820, font(48), (194, 222, 224), 20)
for idx, label in enumerate(["输入", "规则", "验证", "交付"]):
x = 150 + idx * 205
Expand All @@ -473,7 +496,8 @@ def render_hook(segment: dict[str, Any], output_path: Path) -> None:
def render_narrative(segment: dict[str, Any], output_path: Path) -> None:
img, draw = base_image((244, 247, 246))
draw.rounded_rectangle([72, 120, WIDTH - 72, 340], radius=28, fill=(20, 57, 69))
draw.text((112, 170), "从零散提问到稳定流程", fill=(255, 255, 255), font=font(58))
title = narrative_title(segment["text"])
draw.text((112, 170), title, fill=(255, 255, 255), font=font(58))
body = segment["text"]
lines = wrap_text(draw, body, font(48), WIDTH - 180)
y = 460
Expand Down Expand Up @@ -523,7 +547,7 @@ def render_data(segment: dict[str, Any], output_path: Path) -> None:
def render_list(segment: dict[str, Any], output_path: Path) -> None:
img, draw = base_image((238, 244, 243))
draw.text((80, 130), "四个固定动作", fill=(28, 48, 58), font=font(70))
items = ["写清楚输入", "写清楚输出", "列出失败检查", "留下验证命令"]
items = ["输入是什么", "结果长什么样", "失败先查哪里", "证据怎么算完成"]
for idx, item in enumerate(items):
y = 350 + idx * 275
draw.rounded_rectangle([95, y, WIDTH - 95, y + 190], radius=28, fill=(255, 255, 255), outline=(198, 214, 216), width=2)
Expand All @@ -536,7 +560,8 @@ def render_list(segment: dict[str, Any], output_path: Path) -> None:

def render_date(segment: dict[str, Any], output_path: Path) -> None:
img, draw = base_image((247, 248, 244))
draw.text((90, 120), "2026 年 6 月 8 日", fill=(28, 48, 58), font=font(68))
title = "2026 年 6 月 8 日" if "2026" in segment["text"] else "留下运行证明"
draw.text((90, 120), title, fill=(28, 48, 58), font=font(68))
draw.rounded_rectangle([100, 270, WIDTH - 100, 1220], radius=30, fill=(255, 255, 255), outline=(205, 216, 220), width=2)
weekdays = ["一", "二", "三", "四", "五", "六", "日"]
cell_w = 120
Expand All @@ -558,7 +583,7 @@ def render_date(segment: dict[str, Any], output_path: Path) -> None:
fill = (34, 53, 60)
draw.text((x + 42 if day < 10 else x + 28, y), str(day), fill=fill, font=font(44))
day += 1
proof = "每次交付前必须留下运行证明:日志、清单、检查报告和最终文件路径。"
proof = segment["text"]
lines = wrap_text(draw, proof, font(46), WIDTH - 190)
draw_centered_lines(draw, lines, 1350, font(46), (28, 48, 58), 18)
draw_footer(draw, "date")
Expand All @@ -568,7 +593,7 @@ def render_date(segment: dict[str, Any], output_path: Path) -> None:
def render_quote(segment: dict[str, Any], output_path: Path) -> None:
img, draw = base_image((19, 33, 39))
draw.rounded_rectangle([90, 280, WIDTH - 90, 1360], radius=36, fill=(247, 246, 239))
quote = "好提示词不是让 AI 显得聪明,而是让结果可以被复核、被复用、被改进。"
quote = segment["text"]
draw.text((145, 360), '"', fill=(42, 139, 126), font=font(120))
lines = wrap_text(draw, quote, font(60), WIDTH - 260)
draw_centered_lines(draw, lines, 570, font(60), (28, 48, 58), 24)
Expand Down Expand Up @@ -821,6 +846,7 @@ def main() -> int:
raise FileNotFoundError(input_path)

run_dir.mkdir(parents=True, exist_ok=True)
reset_generated_subdirs(run_dir)

env_report = discover_env_files()
credit_report = check_dreamina_credit(args.dreamina_lock_wait) if not args.force_local else {"ok": False, "reason": "force_local"}
Expand Down
Loading
Loading