🔄 卡若AI 同步 2026-03-11 15:17 | 更新:水溪整理归档、卡木、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-03-10
|
||||
2026-03-11
|
||||
@@ -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,
|
||||
|
||||
@@ -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`(提问→回答、节奏感、快速混剪优先片段规则)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
**只保留两个目录**:**切片**、**成片**。其他中间目录不保留。
|
||||
|
||||
|
||||
@@ -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` 做混剪排序。
|
||||
|
||||
# 视频文字稿(带时间戳)
|
||||
|
||||
|
||||
390
03_卡木(木)/木叶_视频内容/视频切片/脚本/quick_montage.py
Normal file
390
03_卡木(木)/木叶_视频内容/视频切片/脚本/quick_montage.py
Normal file
@@ -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()
|
||||
@@ -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'}")
|
||||
|
||||
|
||||
|
||||
@@ -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 个 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user