From 3c287a93a00707dc41a949382d5220b5f3d957fc Mon Sep 17 00:00:00 2001 From: karuo Date: Wed, 11 Mar 2026 15:17:50 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20=E5=8D=A1=E8=8B=A5AI=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=202026-03-11=2015:17=20|=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=9A=E6=B0=B4=E6=BA=AA=E6=95=B4=E7=90=86=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E3=80=81=E5=8D=A1=E6=9C=A8=E3=80=81=E8=BF=90=E8=90=A5=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E5=B7=A5=E4=BD=9C=E5=8F=B0=20|=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=20>20MB:=2011=20=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../记忆系统/structured/last_chat_collect_date.txt | 2 +- .../记忆系统/structured/memory_health.json | 4 +- 03_卡木(木)/木叶_视频内容/视频切片/SKILL.md | 58 ++- .../视频切片/参考资料/热点切片_标准流程.md | 23 ++ .../视频切片/参考资料/高光识别提示词.md | 2 + .../木叶_视频内容/视频切片/脚本/quick_montage.py | 390 ++++++++++++++++++ .../视频切片/脚本/soul_slice_pipeline.py | 44 +- 运营中枢/工作台/gitea_push_log.md | 1 + 运营中枢/工作台/代码管理.md | 1 + 运营中枢/工作台/当前任务看板.md | 1 + 10 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 03_卡木(木)/木叶_视频内容/视频切片/脚本/quick_montage.py diff --git a/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt b/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt index ae37545c..f53408ce 100644 --- a/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt +++ b/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt @@ -1 +1 @@ -2026-03-10 \ No newline at end of file +2026-03-11 \ No newline at end of file diff --git a/02_卡人(水)/水溪_整理归档/记忆系统/structured/memory_health.json b/02_卡人(水)/水溪_整理归档/记忆系统/structured/memory_health.json index 4889cf61..ded0bc3c 100644 --- a/02_卡人(水)/水溪_整理归档/记忆系统/structured/memory_health.json +++ b/02_卡人(水)/水溪_整理归档/记忆系统/structured/memory_health.json @@ -1,6 +1,6 @@ { - "updated": "2026-03-10 15:13:06", - "date": "2026-03-10", + "updated": "2026-03-11 15:17:21", + "date": "2026-03-11", "scan_total": 0, "copied_new": 0, "skipped_idempotent": 0, diff --git a/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md b/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md index 4582b800..6d84b5b6 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md @@ -1,8 +1,8 @@ --- name: 视频切片 -description: Soul派对视频切片 + 切片动效包装(片头/片尾/程序化)+ 剪映思路借鉴(智能剪口播/镜头分割)。触发词含视频剪辑、切片发布、切片动效包装、程序化包装、片头片尾。 +description: Soul派对视频切片 + 快速混剪 + 切片动效包装(片头/片尾/程序化)+ 剪映思路借鉴(智能剪口播/镜头分割)。触发词含视频剪辑、切片发布、快速混剪、切片动效包装、程序化包装、片头片尾。 group: 木 -triggers: 视频剪辑、切片发布、字幕烧录、**切片动效包装、程序化包装、片头片尾、批量封面、视频包装**、镜头切分、场景检测 +triggers: 视频剪辑、切片发布、字幕烧录、**快速混剪、混剪预告、快剪串联、切片动效包装、程序化包装、片头片尾、批量封面、视频包装**、镜头切分、场景检测 owner: 木叶 version: "1.3" updated: "2026-03-03" @@ -14,7 +14,7 @@ updated: "2026-03-03" > **Soul 视频输出**:Soul 剪辑的成片统一导出到 `/Users/karuo/Movies/soul视频/最终版/`,原视频在 `原视频/`,中间产物在 `其他/`。 -> **联动规则**:每次执行视频切片时,自动检查是否需要「切片动效包装」。若用户提到片头/片尾/程序化包装/批量封面,则联动调用 `切片动效包装/10秒视频` 模板渲染,再与切片合成。 +> **联动规则**:每次执行视频切片时,自动检查是否需要「切片动效包装」或「快速混剪」。若用户提到片头/片尾/程序化包装/批量封面,则联动调用 `切片动效包装/10秒视频` 模板渲染,再与切片合成。若用户提到快速混剪/混剪预告/快剪串联,则在切片或成片生成后再调用 `脚本/quick_montage.py` 输出一条节奏版预告。 ## ⭐ Soul派对切片流程(默认) @@ -30,6 +30,21 @@ updated: "2026-03-03" **Soul 竖屏专用**:抖音/首页用竖屏成片、完整参数与流程见 → **`Soul竖屏切片_SKILL.md`**(竖屏 498×1080、crop 参数、批量命令)。 +### 最新切片风格(当前默认) + +以后默认按这套风格出切片与成片: + +| 项 | 当前默认风格 | +|------|------| +| **封面** | **Soul 绿 + 半透明质感 + 深色渐变** | +| **前3秒** | **优先提问→回答**,有提问时 Hook = `question` | +| **标题** | **一句刺激性观点**,文件名 = 封面标题 = `highlights.title` | +| **字幕** | 居中、白字黑描边、关键词亮金黄高亮 | +| **节奏** | 去语助词 + 整体加速 10% | +| **成片尺寸** | 竖屏 **498×1080** | + +这套风格与 `参考资料/高光识别提示词.md`、`参考资料/热点切片_标准流程.md`、`Soul竖屏切片_SKILL.md` 保持一致。 + ### 一键命令(Soul派对专用) #### 一体化流水线(推荐) @@ -41,6 +56,9 @@ python3 soul_slice_pipeline.py --video "/path/to/soul派对会议第57场.mp4" - # 仅重新烧录(字幕转简体后重跑增强) python3 soul_slice_pipeline.py -v "视频.mp4" -n 6 --skip-transcribe --skip-highlights --skip-clips + +# 切片+成片后,额外生成一条快速混剪 +python3 soul_slice_pipeline.py -v "视频.mp4" -n 8 --two-folders --quick-montage ``` 流程:**转录 → 字幕转简体 → 高光识别 → 批量切片 → 增强** @@ -64,6 +82,39 @@ python3 batch_clip.py -i 视频.mp4 -l highlights.json -o clips/ -p soul python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o clips_enhanced/ ``` +### 快速混剪(新增) + +适用场景:已经有 `切片/` 或 `成片/`,需要快速出一条 20~40 秒节奏版预告、招商预热视频、短视频串联版。 + +**默认策略**: + +| 项 | 规则 | +|------|------| +| **顺序** | 优先按 `virality_score` / `rank` 排序;无分数时按序号 | +| **取样** | 每条默认截取 **4 秒**高密度片段 | +| **成片目录输入** | 自动跳过前 **2.6 秒**封面,避免混剪里全是封面 | +| **输出** | 统一分辨率、统一节奏后拼成一条 `快速混剪.mp4` | + +```bash +# 从成片目录生成快速混剪(推荐) +python3 脚本/quick_montage.py \ + -i "/path/to/成片" \ + -o "/path/to/快速混剪.mp4" \ + -l "/path/to/highlights.json" \ + --source-kind final \ + -n 8 \ + -s 4 + +# 一体化流水线里直接附带生成 +python3 脚本/soul_slice_pipeline.py \ + -v "/path/to/原视频.mp4" \ + --two-folders \ + --quick-montage \ + --montage-source finals \ + --montage-max-clips 8 \ + --montage-seconds 4 +``` + #### 按章节主题提取(推荐:第9章单场成片) 以**章节 .md 正文**为来源提取核心主题,再在转录稿中匹配时间,不限于 5 分钟、片段数与章节结构一致。详见 `参考资料/主题片段提取规则.md`。 @@ -492,6 +543,7 @@ python3 soul_enhance.py -c "输出目录/clips/" -l "输出目录/highlights_fro - **剪映逆向分析**:`03_卡木(木)/木叶_视频内容/视频切片/参考资料/剪映_智能剪口播与智能片段分割_逆向分析.md` - 智能剪口播 H5 路径、智能片段分割 config 与参数、自实现建议与合规说明。 - **热点切片标准流程**:`参考资料/热点切片_标准流程.md`(五步、两目录、命令速查)。 +- **高光识别提示词**:`参考资料/高光识别提示词.md`(提问→回答、节奏感、快速混剪优先片段规则)。 --- diff --git a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/热点切片_标准流程.md b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/热点切片_标准流程.md index cff0bfcb..65c1af7b 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/热点切片_标准流程.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/热点切片_标准流程.md @@ -57,6 +57,28 @@ --- +## 可选第六步:快速混剪预告 + +当已经产出 `切片/` 或 `成片/` 后,可再生成一条**节奏更快、便于预热/招商/引流**的混剪视频: + +1. **输入**:优先使用 **成片/** 目录,自动跳过每条前 2.6 秒封面。 +2. **顺序**:优先按 `highlights.json` 里的 `virality_score` / `rank` 排序;没有分数则按序号。 +3. **取样**:每条默认抽 **4 秒**高密度片段,统一分辨率后拼成一条 `快速混剪.mp4`。 + +**命令:** + +```bash +python3 脚本/quick_montage.py \ + -i 成片/ \ + -o 快速混剪.mp4 \ + -l highlights.json \ + --source-kind final \ + -n 8 \ + -s 4 +``` + +--- + ## 流程小结(顺序执行) | 步骤 | 内容 | 产出 | @@ -66,6 +88,7 @@ | 3 | 按时间节点切片提取(30 秒~8 分钟/段) | **切片/** | | 4 | 去语助词(合并在步骤 5) | — | | 5 | 加封面 + 烧录字幕 | **成片/** | +| 6(可选) | 从切片/成片生成节奏预告 | **快速混剪.mp4** | **只保留两个目录**:**切片**、**成片**。其他中间目录不保留。 diff --git a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/高光识别提示词.md b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/高光识别提示词.md index 5ae76bed..36352f05 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/高光识别提示词.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/高光识别提示词.md @@ -111,6 +111,7 @@ CTA的目的是引导用户完成下一步动作。 4. **开场**:尽量以金句或问题开头,3秒内抓住注意力 5. **语助词与空格**:识别时标注或剔除语助词(嗯、啊、呃、那个、就是、然后等)及中间无意义空排/停顿,与主题片段提取规则一致,成片剪辑时由 soul_enhance 统一清理 6. **节奏感**:优先选讲话有步骤、有节奏的片段(起承转合清晰、不拖沓、少重复),避免大段碎碎念或断句混乱 +7. **快速混剪友好**:优先保留开场 3~5 秒就进入观点/问题的片段,避免前摇过长、背景铺垫太久,方便后续 `quick_montage.py` 直接抽取成混剪预告 # 输出格式(严格JSON) @@ -159,6 +160,7 @@ CTA的目的是引导用户完成下一步动作。 6. 只输出JSON,不要其他解释 7. **与主题片段提取一致**:Soul 剪辑全流程(高光识别 → batch_clip → soul_enhance → 竖屏裁剪)继承本提示词;主题片段由章节拆解时,判断标准、语助词与节奏感要求与本提示词保持一致。 8. **有提问时**:片段内有人提问则必须填 `question`,且 `hook_3sec` 与 question 一致,成片前3秒先展示提问再回答;去语助词覆盖整条成片(含提问与回答)。 +9. **用于快速混剪时**:优先让 `transcript_excerpt` 保持强观点、高密度,便于后续按 `virality_score` / `rank` 做混剪排序。 # 视频文字稿(带时间戳) diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/quick_montage.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/quick_montage.py new file mode 100644 index 00000000..b67cec9c --- /dev/null +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/quick_montage.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +快速混剪:从切片/成片目录中抽取高密度片段,快速拼成一条预告混剪。 + +默认策略: +1. 优先读取 highlights.json 的 virality_score / rank 决定顺序 +2. 若输入是成片目录,自动跳过前 2.6 秒封面 +3. 每条取 3~5 秒高密度片段,统一分辨率后拼接 +""" + +import argparse +import json +import os +import random +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def run(cmd: list[str], desc: str = "", check: bool = True) -> subprocess.CompletedProcess: + if desc: + print(f" {desc}...", flush=True) + result = subprocess.run(cmd, capture_output=True, text=True) + if check and result.returncode != 0: + raise RuntimeError(result.stderr.strip() or f"{desc or '命令'} 执行失败") + if desc: + print(" ✓", flush=True) + return result + + +def ffprobe_json(video_path: Path) -> dict: + result = run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "stream=index,codec_type,width,height:format=duration", + "-of", + "json", + str(video_path), + ], + check=True, + ) + return json.loads(result.stdout or "{}") + + +def get_video_info(video_path: Path) -> dict: + info = ffprobe_json(video_path) + streams = info.get("streams", []) + video_stream = next((s for s in streams if s.get("codec_type") == "video"), {}) + has_audio = any(s.get("codec_type") == "audio" for s in streams) + duration = float(info.get("format", {}).get("duration", 0) or 0) + return { + "path": video_path, + "width": int(video_stream.get("width", 0) or 0), + "height": int(video_stream.get("height", 0) or 0), + "duration": duration, + "has_audio": has_audio, + } + + +def parse_clip_index(filename: str) -> int: + import re + + matches = re.findall(r"_(\d+)_", filename) + if matches: + return min(int(m) for m in matches) + fallback = re.search(r"(\d+)", filename) + return int(fallback.group(1)) if fallback else 0 + + +def load_highlights(highlights_path: Path | None) -> list[dict]: + if not highlights_path or not highlights_path.exists(): + return [] + with open(highlights_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "clips" in data: + data = data["clips"] + return data if isinstance(data, list) else [] + + +def detect_source_kind(input_dir: Path, source_kind: str) -> str: + if source_kind != "auto": + return source_kind + name = input_dir.name + if name in {"成片", "clips_enhanced", "clips_enhanced_vertical"}: + return "final" + return "clip" + + +def choose_window(info: dict, source_kind: str, seconds_per_clip: float, skip_cover_sec: float, tail_guard_sec: float) -> tuple[float, float]: + duration = max(0.0, float(info["duration"])) + if duration <= 0.2: + return 0.0, 0.0 + + if source_kind == "final": + start_sec = min(skip_cover_sec, max(0.0, duration - seconds_per_clip - tail_guard_sec)) + else: + lead_in = min(1.2, duration * 0.12) + start_sec = min(lead_in, max(0.0, duration - seconds_per_clip - tail_guard_sec)) + + usable = max(0.0, duration - start_sec - tail_guard_sec) + clip_sec = min(seconds_per_clip, usable) + + if clip_sec < 1.2: + clip_sec = max(0.8, min(duration, seconds_per_clip)) + start_sec = max(0.0, (duration - clip_sec) / 2) + + end_sec = min(duration, start_sec + clip_sec) + return round(start_sec, 3), round(end_sec, 3) + + +def scale_pad_filter(target_w: int, target_h: int, speed_factor: float) -> str: + return ( + f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease," + f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black," + f"setsar=1,fps=30,setpts=PTS/{speed_factor}" + ) + + +def build_candidates(videos: list[Path], highlights: list[dict]) -> list[dict]: + results = [] + for video in videos: + info = get_video_info(video) + clip_index = parse_clip_index(video.name) + highlight = highlights[clip_index - 1] if 0 < clip_index <= len(highlights) else {} + virality = highlight.get("virality_score", 0) if isinstance(highlight, dict) else 0 + rank = highlight.get("rank", clip_index or 999) if isinstance(highlight, dict) else clip_index or 999 + title = ( + (highlight.get("title") if isinstance(highlight, dict) else None) + or video.stem + ) + results.append( + { + "video": video, + "info": info, + "highlight": highlight if isinstance(highlight, dict) else {}, + "clip_index": clip_index, + "virality_score": float(virality or 0), + "rank": int(rank or 999), + "title": str(title), + } + ) + return results + + +def order_candidates(candidates: list[dict], order: str, seed: int) -> list[dict]: + ordered = list(candidates) + if order == "shuffle": + random.Random(seed).shuffle(ordered) + return ordered + if order == "viral": + has_score = any(item.get("virality_score", 0) > 0 for item in ordered) + if has_score: + return sorted( + ordered, + key=lambda item: ( + -item.get("virality_score", 0), + item.get("rank", 999), + item["video"].name, + ), + ) + return sorted( + ordered, + key=lambda item: ( + item.get("clip_index", 999) or 999, + item["video"].name, + ), + ) + + +def render_segment( + source: Path, + output: Path, + start_sec: float, + end_sec: float, + target_w: int, + target_h: int, + has_audio: bool, + speed_factor: float, +) -> None: + duration = max(0.1, end_sec - start_sec) + cmd = [ + "ffmpeg", + "-y", + "-ss", + f"{start_sec:.3f}", + "-i", + str(source), + "-t", + f"{duration:.3f}", + "-vf", + scale_pad_filter(target_w, target_h, speed_factor), + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "23", + "-pix_fmt", + "yuv420p", + ] + if has_audio: + cmd += ["-af", f"atempo={speed_factor}", "-c:a", "aac", "-b:a", "128k"] + else: + cmd += ["-an"] + cmd.append(str(output)) + run(cmd, check=True) + + +def concat_segments(segment_paths: list[Path], output_path: Path) -> None: + concat_list = output_path.with_suffix(".txt") + with open(concat_list, "w", encoding="utf-8") as f: + for path in segment_paths: + f.write(f"file '{path}'\n") + + result = subprocess.run( + [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_list), + "-c", + "copy", + str(output_path), + ], + capture_output=True, + text=True, + ) + if result.returncode == 0 and output_path.exists(): + return + + run( + [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_list), + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "22", + "-c:a", + "aac", + "-b:a", + "128k", + str(output_path), + ], + desc="拼接混剪视频", + check=True, + ) + + +def write_manifest(output_path: Path, items: list[dict], source_kind: str, seconds_per_clip: float) -> None: + manifest = { + "output": str(output_path), + "source_kind": source_kind, + "seconds_per_clip": seconds_per_clip, + "clips": items, + } + with open(output_path.with_suffix(".json"), "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + + +def main() -> None: + parser = argparse.ArgumentParser(description="快速混剪:从切片/成片目录一键生成节奏版预告") + parser.add_argument("--input-dir", "-i", required=True, help="输入目录(切片/成片)") + parser.add_argument("--output", "-o", required=True, help="输出混剪视频路径") + parser.add_argument("--highlights", "-l", help="highlights.json 路径,用于按热度排序") + parser.add_argument("--source-kind", choices=["auto", "clip", "final"], default="auto", help="输入目录类型") + parser.add_argument("--order", choices=["viral", "chronological", "shuffle"], default="viral", help="混剪顺序") + parser.add_argument("--max-clips", "-n", type=int, default=8, help="最多取多少条视频") + parser.add_argument("--seconds-per-clip", "-s", type=float, default=4.0, help="每条混剪截取秒数") + parser.add_argument("--skip-cover-sec", type=float, default=2.6, help="成片目录默认跳过前几秒封面") + parser.add_argument("--tail-guard-sec", type=float, default=0.6, help="片尾预留秒数,避免切到收尾黑屏") + parser.add_argument("--speed-factor", type=float, default=1.05, help="混剪整体加速倍数") + parser.add_argument("--seed", type=int, default=42, help="shuffle 时的随机种子") + args = parser.parse_args() + + input_dir = Path(args.input_dir).resolve() + output_path = Path(args.output).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + if not input_dir.exists(): + raise SystemExit(f"❌ 输入目录不存在: {input_dir}") + + videos = sorted([p for p in input_dir.glob("*.mp4") if p.is_file()]) + if not videos: + raise SystemExit(f"❌ 目录下没有 mp4: {input_dir}") + + source_kind = detect_source_kind(input_dir, args.source_kind) + highlights = load_highlights(Path(args.highlights).resolve()) if args.highlights else [] + candidates = build_candidates(videos, highlights) + ordered = order_candidates(candidates, args.order, args.seed) + + if args.max_clips > 0: + ordered = ordered[: args.max_clips] + + if not ordered: + raise SystemExit("❌ 没有可用视频用于混剪") + + target_w = ordered[0]["info"]["width"] or 498 + target_h = ordered[0]["info"]["height"] or 1080 + + print("=" * 60) + print("⚡ 快速混剪") + print("=" * 60) + print(f"输入目录: {input_dir}") + print(f"目录类型: {source_kind}") + print(f"选取数量: {len(ordered)}") + print(f"每条秒数: {args.seconds_per_clip}") + print(f"输出视频: {output_path}") + print("=" * 60) + + manifest_items = [] + temp_dir = Path(tempfile.mkdtemp(prefix="quick_montage_")) + segment_paths: list[Path] = [] + try: + for idx, item in enumerate(ordered, 1): + info = item["info"] + start_sec, end_sec = choose_window( + info, + source_kind, + args.seconds_per_clip, + args.skip_cover_sec, + args.tail_guard_sec, + ) + if end_sec - start_sec < 0.8: + print(f" 跳过过短视频: {item['video'].name}", flush=True) + continue + segment_path = temp_dir / f"segment_{idx:02d}.mp4" + print( + f" [{idx}/{len(ordered)}] {item['title']} {start_sec:.1f}s -> {end_sec:.1f}s", + flush=True, + ) + render_segment( + item["video"], + segment_path, + start_sec, + end_sec, + target_w, + target_h, + info["has_audio"], + args.speed_factor, + ) + segment_paths.append(segment_path) + manifest_items.append( + { + "index": idx, + "file": item["video"].name, + "title": item["title"], + "start_sec": start_sec, + "end_sec": end_sec, + "virality_score": item.get("virality_score", 0), + } + ) + + if not segment_paths: + raise SystemExit("❌ 没有成功生成任何混剪片段") + + concat_segments(segment_paths, output_path) + write_manifest(output_path, manifest_items, source_kind, args.seconds_per_clip) + + size_mb = output_path.stat().st_size / (1024 * 1024) + print() + print("=" * 60) + print("✅ 快速混剪完成") + print("=" * 60) + print(f"输出: {output_path}") + print(f"大小: {size_mb:.1f}MB") + print(f"清单: {output_path.with_suffix('.json')}") + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_slice_pipeline.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_slice_pipeline.py index 905d3003..8060f7b6 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_slice_pipeline.py +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_slice_pipeline.py @@ -4,7 +4,7 @@ Soul 切片一体化流水线 视频制作(封面/Hook格式)+ 视频切片 -流程:转录 → 字幕转简体 → 高光识别(AI) → 批量切片 → 增强(封面+字幕+CTA) +流程:转录 → 字幕转简体 → 高光识别(AI) → 批量切片 → 增强(封面+字幕+CTA) → 快速混剪(可选) """ import argparse import atexit @@ -116,6 +116,10 @@ def main(): parser.add_argument("--two-folders", action="store_true", help="仅用两文件夹:切片、成片(默认 clips、clips_enhanced)") parser.add_argument("--slices-only", action="store_true", help="只做到切片(MLX 转录→高光→批量切片),不跑成片增强") parser.add_argument("--prefix", default="", help="切片文件名前缀,如 soul112") + parser.add_argument("--quick-montage", action="store_true", help="额外生成一条快速混剪视频") + parser.add_argument("--montage-source", choices=["auto", "clips", "finals"], default="auto", help="快速混剪使用切片还是成片") + parser.add_argument("--montage-max-clips", type=int, default=8, help="快速混剪最多取多少条片段") + parser.add_argument("--montage-seconds", type=float, default=4.0, help="快速混剪每条截取秒数") args = parser.parse_args() video_path = Path(args.video).resolve() @@ -245,6 +249,19 @@ def main(): sys.exit(1) if getattr(args, "slices_only", False): + if getattr(args, "quick_montage", False): + montage_output = base_dir / "快速混剪.mp4" + montage_cmd = [ + sys.executable, + str(SCRIPT_DIR / "quick_montage.py"), + "--input-dir", str(clips_dir), + "--output", str(montage_output), + "--highlights", str(highlights_path), + "--source-kind", "clip", + "--max-clips", str(args.montage_max_clips), + "--seconds-per-clip", str(args.montage_seconds), + ] + run(montage_cmd, "生成快速混剪", timeout=600, check=False) print() print("=" * 60) print("✅ 切片阶段完成(--slices-only)") @@ -277,12 +294,37 @@ def main(): for f in sorted(clips_dir.glob("*.mp4")): shutil.copy(f, enhanced_dir / f.name) + if getattr(args, "quick_montage", False): + montage_output = base_dir / "快速混剪.mp4" + if args.montage_source == "clips": + montage_input = clips_dir + montage_kind = "clip" + elif args.montage_source == "finals": + montage_input = enhanced_dir + montage_kind = "final" + else: + montage_input = enhanced_dir if enhanced_count > 0 else clips_dir + montage_kind = "final" if enhanced_count > 0 else "clip" + montage_cmd = [ + sys.executable, + str(SCRIPT_DIR / "quick_montage.py"), + "--input-dir", str(montage_input), + "--output", str(montage_output), + "--highlights", str(highlights_path), + "--source-kind", montage_kind, + "--max-clips", str(args.montage_max_clips), + "--seconds-per-clip", str(args.montage_seconds), + ] + run(montage_cmd, "生成快速混剪", timeout=600, check=False) + print() print("=" * 60) print("✅ 流水线完成") print("=" * 60) print(f" 切片: {clips_dir}") print(f" 成片: {enhanced_dir}") + if getattr(args, "quick_montage", False): + print(f" 混剪: {base_dir / '快速混剪.mp4'}") print(f" 清单: {base_dir / 'clips_manifest.json'}") diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md index ec807808..2b6506da 100644 --- a/运营中枢/工作台/gitea_push_log.md +++ b/运营中枢/工作台/gitea_push_log.md @@ -288,3 +288,4 @@ | 2026-03-11 14:38:13 | 🔄 卡若AI 同步 2026-03-11 14:38 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-11 14:45:54 | 🔄 卡若AI 同步 2026-03-11 14:45 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-11 14:55:15 | 🔄 卡若AI 同步 2026-03-11 14:55 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | +| 2026-03-11 15:07:58 | 🔄 卡若AI 同步 2026-03-11 15:07 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md index 6bf4a0e9..3bd3fb8f 100644 --- a/运营中枢/工作台/代码管理.md +++ b/运营中枢/工作台/代码管理.md @@ -291,3 +291,4 @@ | 2026-03-11 14:38:13 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-11 14:38 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-11 14:45:54 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-11 14:45 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-11 14:55:15 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-11 14:55 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | +| 2026-03-11 15:07:58 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-11 15:07 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | diff --git a/运营中枢/工作台/当前任务看板.md b/运营中枢/工作台/当前任务看板.md index afa0f279..401c10bc 100644 --- a/运营中枢/工作台/当前任务看板.md +++ b/运营中枢/工作台/当前任务看板.md @@ -14,6 +14,7 @@ | T003 | 卡若AI 4项优化落地 | 大总管 | 🔄 执行中 | 减重+收口+规则+输出+护栏 | 2026-02-25 21:00 | | T004 | 一人公司Agent开发(第一优先级) | 火炬+木叶 | 🔄 执行中 | 视频切片/文章/直播/小程序/朋友圈→聚合平台 5% | 2026-02-27 | | T005 | 玩值电竞推进(第二优先级) | 火炬 | 🔄 执行中 | Docker 3001,MongoDB wanzhi_esports 25% | 2026-02-27 | +| T006 | 视频切片与快速混剪能力补强 | 木叶+火炬 | ✅ 已完成 | 已收口最新切片风格/提示词/规则,新增 quick_montage 与流水线一键入口 | 2026-03-11 23:23 | ---