Skill: riclinccc/video-clip-editor (version 1.0.0)

--- name: video-clip-editor description: "Video clip editing skill for automatically analyzing video content and generating CapCut draft templates. Uses local Whisper for speech transcription, Qwen-VL-Plus for visual scene description, and edge-tts for narration audio. Intelligently splits by dialogue/keywords/highlights, applies koubo jump-cut editing, and outputs draft_content.json ready to import into CapCut. Use this skill whenever the user mentions: video editing, clip, CapCut draft, highlight extraction, subtitle editing, keyword clipping, movie clip, narration, scene description, or uploads a video and asks to edit it." license: MIT --- # Video Clip Editor Skill ## Strict Output Contract Every run MUST produce exactly these 4 files in `video_clipper_output/`: ``` video_clipper_output/ ├── narration_XXX.mp3 ← 配音音频，每段独立文件，时间戳对齐 ├── subtitles.srt ← 字幕，时间轴与 mp3 严格对应 ├── clip_XXX.mp4 ← 视频切片，每段独立文件 └── draft_content.json ← 剪映草稿文件 ``` **不允许缺少任何一个。每个 mp3/mp4 的时间轴必须与 srt 中对应条目精确匹配。** --- ## Two Operating Modes ### Mode A — Movie Narration (电影解说) User provides movie name + video file → LLM generates cinematic narration. ### Mode B — Interview / Dialogue (剪口播) User provides video with real speech → Whisper transcribes → koubo jump-cut editing. --- ## Overall Workflow ``` [Step 0] Collect inputs (movie name / characters / voice) ↓ [Step 1] ffprobe → original aspect ratio + video duration ↓ [Step 2] Mode A: search movie plot online → LLM generates narration script Mode B: Whisper transcription → koubo clip analysis ↓ [Step 3] Show clip plan → wait for user approval ↓ [Step 4] Generate narration_XXX.mp3 (one per clip, edge-tts) Measure ACTUAL mp3 duration via ffprobe ↓ [Step 5] Generate clip_XXX.mp4 (one per clip, ffmpeg) clip duration = mp3 duration (exact match) ↓ [Step 6] Generate subtitles.srt (timestamps from mp3 durations) ↓ [Step 7] Generate draft_content.json (JianYing/CapCut importable) ↓ [Step 8] Show final output message ``` --- ## Step 0: Collect Inputs Ask before starting: **Mode A:** > "请提供：① 电影名称 ② 视频文件完整路径 ③ 片中主要角色（如：主角陆永瑜，反派陆金强） ④ 旁白语音（默认：zh-CN-XiaoxiaoNeural）" **Mode B:** > "请提供：① 视频文件路径 ② 说话人名称（如：主持人Alex，嘉宾Sarah，可跳过） ③ 旁白语音（默认：zh-CN-XiaoxiaoNeural）" ```python movie_name = "误判" video_path = "C:\\Movies\\误判.mp4" characters = [{"name": "陆永瑜", "role": "主角"}, {"name": "陆金强", "role": "反派"}] tts_voice = "zh-CN-XiaoxiaoNeural" output_dir = "video_clipper_output" ``` ### Project Name Conflict Detection (草稿名冲突处理) Before generating any files, check if a project with the same name already exists in the JianYing draft directory. If it does, append `(1)`, `(2)`, `(3)` etc. to avoid overwriting. ```python import os, re JIANYING_DRAFT_DIRS = [ # Windows os.path.expandvars(r"%LOCALAPPDATA%\JianyingPro\User Data\Projects\com.lveditor.draft"), # macOS os.path.expanduser("~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft"), ] def resolve_project_name(base_name: str) -> str: """ Check JianYing draft directory for name conflicts. Returns base_name if no conflict, or base_name(1) / base_name(2) / ... if taken. """ draft_dir = next((d for d in JIANYING_DRAFT_DIRS if os.path.isdir(d)), None) if not draft_dir: return base_name # can't check, proceed with original name existing = set(os.listdir(draft_dir)) if base_name not in existing: return base_name # Find next available suffix i = 1 while f"{base_name}({i})" in existing: i += 1 resolved = f"{base_name}({i})" # Notify user print(f"⚠️ 草稿名称「{base_name}」已存在，自动重命名为「{resolved}」以避免覆盖。") return resolved # Call at the start, before generating any files project_name = resolve_project_name(movie_name) output_dir = f"video_clipper_output_{project_name}" ``` Tell the user before proceeding: ``` ⚠️ 检测到剪映草稿「误判」已存在 本次项目将命名为「误判(1)」，输出到 video_clipper_output_误判(1)/ 继续？(是 / 自定义名称) ``` --- ## Step 1: Probe Video + Calculate Narration Budget ```bash # Get width, height, duration ffprobe -v error -select_streams v:0 \ -show_entries stream=width,height,duration \ -of json <video_path> ``` Store: `original_width`, `original_height`, `video_duration_seconds`. ### Narration Word Budget (字数预算) **Core rule: 1 minute of output video = 200 Chinese characters of narration** ```python CHARS_PER_MINUTE = 200 # 1 min output = 200 chars TARGET_DURATION_S = 60 # target output: ~60 seconds DURATION_RANGE = (45, 75) # acceptable range: 45s – 75s def calculate_narration_budget(video_duration_seconds: float) -> dict: """ Calculate how many characters and segments to generate. Output video is always capped to ~60 seconds (±15s), regardless of source length. """ target_chars = CHARS_PER_MINUTE # 200 chars → ~60s output min_chars = int(CHARS_PER_MINUTE * DURATION_RANGE[0] / 60) # 150 chars → 45s max_chars = int(CHARS_PER_MINUTE * DURATION_RANGE[1] / 60) # 250 chars → 75s # Typical segment: 25 chars → ~7.5s each ideal_segment_chars = 25 ideal_segment_count = round(target_chars / ideal_segment_chars) # ~8 segments return { "target_chars": target_chars, # 200 "min_chars": min_chars, # 150 "max_chars": max_chars, # 250 "ideal_segment_count": ideal_segment_count, # ~8 "chars_per_segment": ideal_segment_chars, # 25 "target_output_secs": TARGET_DURATION_S, # 60 "source_duration_secs": video_duration_seconds } budget = calculate_narration_budget(video_duration_seconds) ``` **Tell user the budget before searching:** ``` 📐 旁白字数预算 源视频时长 ：X 分 X 秒 目标输出时长：约 60 秒（可接受范围：45–75 秒） 旁白总字数 ：约 200 字（150–250 字区间） 预计片段数 ：约 8 段，每段约 25 字 ``` **Enforcement during narration generation:** - Each segment: aim for `chars_per_segment` (25 chars), hard max 35 chars - After all segments written: count total chars - < 150 chars → expand existing segments or add 1–2 more - \> 250 chars → trim or merge lowest-importance segments - Final output video duration MUST land in 45–75s range --- ## Step 2: Build Clip List ### Mode A — Online Search for Precise Scene Timestamps **Search strategy — run ALL queries, then synthesize:** ```python def build_search_queries(movie_name: str) -> list: return [ f"{movie_name} 剧情详解 起因发展高潮结局 时间点", f"{movie_name} 电影解说 精彩片段时间轴 几分几秒", f"{movie_name} 剧情时间线 关键场景 时间戳", f"{movie_name} plot breakdown scene timestamps minutes", f"{movie_name} 完整剧情 开头结尾 高潮在第几分钟", ] ``` **Parse search results into events with PRECISE timestamps:** Each event MUST have a specific `video_timestamp` (not a vague range). If search results give ranges like "第30–40分钟", take the midpoint (35:00). If no timestamp is found for an event, mark `timestamp_confidence: "estimated"`. ```json [ { "event_id": "evt_001", "stage": "起因", "video_timestamp": "00:03:20", "video_seconds": 200, "timestamp_confidence":"exact", "description": "陆永瑜在父亲书房发现账本，意识到父亲长期贪腐" }, { "event_id": "evt_002", "stage": "发展", "video_timestamp": "00:18:45", "video_seconds": 1125, "timestamp_confidence":"exact", "description": "陆金强约见陆永瑜，暗示他撤回举报，遭到拒绝" }, { "event_id": "evt_003", "stage": "发展", "video_timestamp": "00:35:10", "video_seconds": 2110, "timestamp_confidence":"estimated", "description": "律师张远接手案件，发现监控录像有剪辑痕迹" }, { "event_id": "evt_004", "stage": "高潮", "video_timestamp": "01:12:30", "video_seconds": 4350, "timestamp_confidence":"exact", "description": "庭审现场，张远当庭播放未剪辑原版监控，陆金强伪证当场崩塌" }, { "event_id": "evt_005", "stage": "结局", "video_timestamp": "01:28:00", "video_seconds": 5280, "timestamp_confidence":"exact", "description": "陆永瑜无罪释放，走出法院，与家人重逢" } ] ``` **Segment count must match `budget["ideal_segment_count"]` (≈8).** If search only returns 4–5 events, expand by splitting long stages into sub-events. If search returns 15+ events, merge adjacent same-stage events. **Each clip duration = narration TTS duration (measured AFTER TTS generation in Step 4).** Source video timestamp marks WHERE to cut, not HOW LONG to hold. ### Mode A — Narration Script via LLM Generate narration for EACH event. Total char count must stay within budget (150–250 chars). Then call LLM for each event: **LLM Narration Prompt:** ``` 你是专业电影解说文案写手，风格参考"木鱼水心"。 电影：{movie_name} 角色：{characters} 事件：{event_description} 阶段：{stage}（起因/发展/高潮/结局） 上段旁白：{prev_narration} 本段目标字数：{target_chars_this_segment} 字（全片总字数预算：{total_budget} 字，已用：{used_chars} 字，剩余：{remaining_chars} 字） 要求： - 严格控制在 {target_chars_this_segment} 字左右（±3字），不得超过 35 字 - 第一段必须用钩子开头：「眼前这个{角色}，只是{做了件小事}，才...」 - 中间段用「随后」「然而」「就在此时」衔接 - 极具感染力，制造悬念 - 禁止平铺直叙，禁止「画面中」「镜头转向」 只输出旁白文案，不要解释。 ``` Call via `build_payload.generate_cinematic_narration()`. ### Mode B — Whisper + Koubo Analysis Run `scripts/whisper_transcribe.py` → detect silent gaps > 1.5s → score segments → apply koubo cuts → enforce 90s budget. ### Clip List Format (共用) ```python clips = [ { "clip_id": "clip_001", "start_time": 0.0, # seconds in source video "end_time": 6.3, # seconds in source video "narration_text": "眼前这个陆永瑜，只是举报了父亲的贪腐...", "keep_original_audio": False, # True = keep source audio, no TTS "stage": "起因", "speaker_name": None, }, ... ] ``` --- ## Step 3: Approval Gate **MANDATORY** — Show the FULL detailed clip plan BEFORE generating any files. Include every clip with: index, source timestamp, stage, estimated duration, full narration text. ``` ╔══════════════════════════════════════════════════════════════════════╗ ║ 📋 《误判(1)》剪辑方案预览 共 8 段 | 预估总时长：约 52 秒 ║ ╚══════════════════════════════════════════════════════════════════════╝ ┌─ clip_001 ────────────────────────────────────────────────────────┐ │ 阶段：起因 │ │ 源片段：00:02:15 → 00:02:22 (约 7s) │ │ 旁白文案： │ │ 「眼前这个陆永瑜，只是举报了父亲的贪腐行为，才发现自己 │ │ 已经踏入了一个无法回头的深渊...」 │ │ 音频：narration_clip_001.mp3 | 视频：clip_001.mp4 │ └───────────────────────────────────────────────────────────────────┘ ┌─ clip_002 ────────────────────────────────────────────────────────┐ │ 阶段：发展 │ │ 源片段：00:18:40 → 00:18:46 (约 6s) │ │ 旁白文案： │ │ 「随后，陆金强悄悄接触了关键证人，一场精心设计的陷阱， │ │ 正在悄无声息地收紧。」 │ │ 音频：narration_clip_002.mp3 | 视频：clip_002.mp4 │ └───────────────────────────────────────────────────────────────────┘ ┌─ clip_003 ────────────────────────────────────────────────────────┐ │ 阶段：发展 │ │ 源片段：00:35:10 → 00:35:16 (约 6s) │ │ 旁白文案： │ │ 「然而，就在案件似乎尘埃落定之际，律师张远发现了一处细节—— │ │ 这个细节，将让所有人的判断彻底崩塌。」 │ │ 音频：narration_clip_003.mp3 | 视频：clip_003.mp4 │ └───────────────────────────────────────────────────────────────────┘ ... （以此格式列出所有 N 个片段） ══════════════════════════════════════════════════════════════════════ 项目名称 ：误判(1) 输出目录 ：video_clipper_output_误判(1)/ 视频比例 ：1920×1080（保持原始） 旁白语音 ：zh-CN-XiaoxiaoNeural 旁白总字数：213 字 ✅（目标 200 字，范围 150–250 字） 预估时长 ：约 64 秒 ✅（目标 60 秒，范围 45–75 秒） ══════════════════════════════════════════════════════════════════════ 确认开始生成？(是 / 修改某段文案 / 删除某段 / 全部重新生成) ``` **Rules for this display:** - Show ALL clips, no truncation — even if there are 20+ clips - Each clip box must contain: stage, source timestamp range, estimated duration, full narration text, output filenames - Show project name WITH conflict suffix (e.g. `误判(1)`) so user sees the final name before committing - Wait for explicit user approval before proceeding to Step 4 --- ## Step 4: Generate narration_XXX.mp3 One MP3 per clip. Filename: `narration_clip_001.mp3`, `narration_clip_002.mp3`, ... ```python import edge_tts, asyncio, subprocess async def tts_one(text: str, path: str, voice: str): tts = edge_tts.Communicate(text.strip("[]"), voice=voice) await tts.save(path) def get_mp3_duration(path: str) -> float: """Measure ACTUAL mp3 duration — used to set clip_XXX.mp4 length.""" result = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path], capture_output=True, text=True, check=True ) return float(result.stdout.strip()) # Generate + measure for clip in clips: mp3_path = f"{output_dir}/narration_{clip['clip_id']}.mp3" asyncio.run(tts_one(clip["narration_text"], mp3_path, tts_voice)) clip["mp3_path"] = mp3_path clip["mp3_duration"] = get_mp3_duration(mp3_path) # ACTUAL seconds # Update end_time to match mp3 duration exactly clip["end_time"] = clip["start_time"] + clip["mp3_duration"] ``` **After this step, `clip["mp3_duration"]` is the authoritative duration for all downstream steps.** --- ## Step 5: Generate clip_XXX.mp4 One MP4 per clip. Duration = `clip["mp3_duration"]` exactly. Filename: `clip_001.mp4`, `clip_002.mp4`, ... ```python import subprocess, os def render_clip(clip: dict, video_path: str, output_dir: str, original_width: int, original_height: int): """ Extract one clip from source video. - Duration locked to mp3_duration (exact match with narration audio) - Preserves original aspect ratio - Mutes source audio if narration overlay (keep_original_audio=False) - Keeps source audio if keep_original_audio=True """ out_path = f"{output_dir}/{clip['clip_id']}.mp4" duration = clip["mp3_duration"] # locked to mp3 base_cmd = [ "ffmpeg", "-y", "-ss", str(clip["start_time"]), "-t", str(duration), "-i", video_path, ] if not clip.get("keep_original_audio") and clip.get("mp3_path"): # Merge narration audio into clip cmd = base_cmd + [ "-i", clip["mp3_path"], "-map", "0:v:0", "-map", "1:a:0", "-c:v", "libx264", "-preset", "fast", "-vf", f"scale={original_width}:{original_height}" ":force_original_aspect_ratio=decrease:flags=lanczos", "-c:a", "aac", "-b:a", "192k", "-shortest", out_path ] elif clip.get("keep_original_audio"): # Keep source audio, no narration cmd = base_cmd + [ "-c:v", "libx264", "-preset", "fast", "-vf", f"scale={original_width}:{original_height}" ":force_original_aspect_ratio=decrease:flags=lanczos", "-c:a", "aac", "-b:a", "192k", out_path ] else: # Silent clip (no audio at all) cmd = base_cmd + [ "-c:v", "libx264", "-preset", "fast", "-vf", f"scale={original_width}:{original_height}" ":force_original_aspect_ratio=decrease:flags=lanczos", "-an", out_path ] subprocess.run(cmd, capture_output=True, check=True) clip["mp4_path"] = out_path print(f" {clip['clip_id']}.mp4 {duration:.2f}s") return out_path ``` --- ## Step 6: Generate subtitles.srt Timestamps derived from `mp3_duration` — NOT from source video timestamps. Each clip starts where the previous ended (sequential playback timeline). ```python def generate_srt(clips: list, output_path: str): """ Build SRT where each entry's timing matches the clip's position in the final concatenated video, not the source video. """ def fmt(s: float) -> str: h, r = divmod(s, 3600) m, s = divmod(r, 60) return f"{int(h):02d}:{int(m):02d}:{int(s):02d},{int((s%1)*1000):03d}" cursor = 0.0 # running position in output video timeline entries = [] for i, clip in enumerate(clips, 1): duration = clip["mp3_duration"] start = cursor end = cursor + duration text = clip.get("narration_text") or clip.get("transcript", "") speaker = clip.get("speaker_name") label = f"[{speaker}] {text}" if speaker else text entries.append(f"{i}\n{fmt(start)} --> {fmt(end)}\n{label}\n") cursor = end # advance timeline with open(output_path, "w", encoding="utf-8") as f: f.write("\n".join(entries)) print(f"SRT saved: {output_path} ({len(entries)} entries)") ``` --- ## Step 7: Generate JianYing Draft Files **MUST output exactly 2 files** — both required for a valid importable draft: 1. `draft_meta_info.json` 2. `draft_content.json` Call `scripts/jianying_draft.py` → `generate_jianying_draft()`. --- ### JianYing Draft Spec (剪映专业版导入规范) | Field | Rule | |-------|------| | `draft_version` | Always `"10.8.0"` | | Resolution | `1920 × 1080`, frame rate `30` | | Time unit | **Milliseconds**, 13-digit timestamp | | Media path | Windows-style: `D:/path/to/clip_001.mp4` | | Subtitle format | JianYing XML: `<font color="#FFFFFF"><size=60>文字</size></font>` | | All IDs | Unique, non-repeating (use `uuid4()`) | | JSON | Strict — no trailing commas, no comments | --- ### draft_meta_info.json ```json { "cloud_package_attributes": "", "create_time": 1700000000, [REDACTED_SECRET]: false, "draft_cloud_purchase_info": "", "draft_cloud_template_id": "", "draft_cloud_tutorial_info": "", [REDACTED_SECRET]: "", "draft_cover": "", "draft_deeplink_url": "", "draft_enterprise_info": { "draft_enterprise_extra": "", "draft_enterprise_id": "", "draft_enterprise_name": "" }, "draft_fold_path": "", "draft_id": "{uuid}", "draft_is_ai_packaging_used": false, "draft_is_ai_shorts_used": false, "draft_is_ai_translate_used": false, "draft_is_article_video_used": false, "draft_is_from_deeplink": false, "draft_is_invisible": false, "draft_materials": [], "draft_name": "{project_name}", "draft_need_rename": false, "draft_new_version": "", "draft_removable_storage_device": "", "draft_root_path": "{output_dir_abs_path}", "draft_timeline_materials_size_": 0, "draft_type": "", "tm_draft_create": 1700000000, "tm_draft_modified": 1700000000, "tm_duration": {total_duration_ms} } ``` --- ### draft_content.json Structure ```json { "canvas_config": { "height": 1080, "ratio": "original", "width": 1920 }, "color_space": 0, "config": { "adjust_max_index": 1, "attachment_info": [], "combination_max_index": 1, "export_range": null, "extract_audio_last_index": 1, "lyrics_recognition_id": "", "lyrics_sync": true, "lyrics_taskinfo": [], "maintrack_adsorb": true, "material_save_mode": 0, "multi_language_current": "none", "multi_language_list": [], "multi_language_main": "none", "multi_language_mode": "none", "original_sound_last_index": 1, "record_audio_last_index": 1, "sticker_max_index": 1, "subtitle_recognition_id": "", "subtitle_sync": true, "subtitle_taskinfo": [], "system_font_list": [], "video_mute": false, "zoom_info_params": null }, "cover": "", "create_time": 0, "duration": {total_duration_ms}, "extra_info": null, "fps": 30.0, "free_render_index_mode_on": false, "group_container": null, "id": "{draft_uuid}", "keyframe_graph_list": [], "keyframes": { "adjusts": [], "audios": [], "effects": [], "filters": [], "handwrites": [], "stickers": [], "texts": [], "videos": [] }, "lyrics_effects_size_": 0, "materials": { "audios": [ ...narration_audio_materials... ], "videos": [ ...video_clip_materials... ], "texts": [ ...subtitle_text_materials... ] }, "mutable_config": null, "name": "{project_name}", "new_version": "10.8.0", "platform": { "app_id": 3704, "app_source": "lv", "app_version": "5.9.0", "device_id": "", "hard_disk_id": "", "mac_id": "", "os": "windows", "os_version": "" }, "relationships": [], "render_index_track_mode_on": false, "retouch_cover": null, "source": "default", "static_cover_image_path": "", "time_marks": null, "tracks": [ { "attribute": 0, "flag": 0, "id": "{track_video_uuid}", "is_default_name": true, "name": "", "segments": [ ...video_segments... ], "type": "video" }, { "attribute": 0, "flag": 0, "id": "{track_audio_uuid}", "is_default_name": true, "name": "", "segments": [ ...audio_segments... ], "type": "audio" }, { "attribute": 0, "flag": 0, "id": "{track_text_uuid}", "is_default_name": true, "name": "", "segments": [ ...text_segments... ], "type": "text" } ], "update_time": 0, "version": 360000 } ``` --- ### Segment Templates **Video segment:** ```json { "cartoon": false, "clip": { "alpha": 1.0, "flip": { "horizontal": false, "vertical": false }, "rotation": 0.0, "scale": { "x": 1.0, "y": 1.0 }, "transform": { "x": 0.0, "y": 0.0 } }, "common_keyframes": [], "enable_adjust": true, "enable_color_correct_adjust": false, "enable_color_wheels": false, "enable_lut": false, "enable_smart_color_adjust": false, "extra_material_refs": [], "group_id": "", "hdr_settings": null, "id": "{segment_uuid}", "intensifies_audio": false, "is_placeholder": false, "is_tone_modify": false, "keyframe_refs": [], "last_nonzero_volume": 1.0, "material_id": "{material_uuid}", "render_index": 0, "responsive_layout": { "enable": false, "horizontal_pos_layout": 0, "size_layout": 0, "target_follow": "", "vertical_pos_layout": 0 }, "reverse": false, "source_timerange": { "duration": {clip_duration_ms}, "start": {clip_source_start_ms} }, "speed": 1.0, "target_timerange": { "duration": {clip_duration_ms}, "start": {timeline_start_ms} }, "template_id": "", "template_scene": "default", "track_attribute": 0, "track_render_index": 0, "uniform_scale": { "on": true, "value": 1.0 }, "visible": true, "volume": 0.0 } ``` Note: `volume: 0.0` for narration clips (audio replaced by TTS), `volume: 1.0` for keep_original_audio clips. **Audio segment (narration TTS):** ```json { "app_id": 0, "category_id": "", "category_name": "local", "check_flag": 63487, "duration": {mp3_duration_ms}, "effect_id": "", "id": "{segment_uuid}", "intensifies_path": "", "is_ai_clone_tone": false, "is_text_edit_overdub": false, "is_unified_beauty_mode": false, "local_material_id": "{audio_material_uuid}", "material_id": "{audio_material_uuid}", "material_name": "narration_{clip_id}", "render_index": 0, "source_timerange": { "duration": {mp3_duration_ms}, "start": 0 }, "speed": 1.0, "target_timerange": { "duration": {mp3_duration_ms}, "start": {timeline_start_ms} }, "type": "extract_music", "volume": 1.0 } ``` **Text segment (subtitle):** ```json { "content": "{subtitle_xml}", "id": "{segment_uuid}", "is_style_definition_segment": false, "keyframe_refs": [], "material_id": "{text_material_uuid}", "render_index": 0, "target_timerange": { "duration": {mp3_duration_ms}, "start": {timeline_start_ms} }, "track_attribute": 0, "track_render_index": 0, "visible": true, "z_index": 0 } ``` **Subtitle XML format (字幕必须用剪映 XML 格式):** ```xml <font color="#FFFFFF"><size=60>眼前这个陆永瑜，只是举报了父亲的贪腐行为</size></font> ``` --- ### Material Templates **Video material:** ```json { "audio_fade": null, "cartoon_path": "", "category_id": "", "category_name": "", "check_flag": 63487, "crop": { "lower_left_x": 0.0, "lower_left_y": 1.0, "lower_right_x": 1.0, "lower_right_y": 1.0, "upper_left_x": 0.0, "upper_left_y": 0.0, "upper_right_x": 1.0, "upper_right_y": 0.0 }, "crop_ratio": "free", "crop_scale": 1.0, "duration": {clip_duration_ms}, "extra_type_option": 0, "file_Path": "D:/{output_dir}/{clip_id}.mp4", "formula_id": "", "id": "{material_uuid}", "import_time": 1700000000, "import_time_ms": 1700000000000, "item_source": 1, "md5": "", "metetype": "video", "roughcut_time_range": { "duration": -1, "start": -1 }, "sub_time_range": { "duration": -1, "start": -1 }, "type": "video", "video_algorithm": { "algorithms": [], "deflicker": null, "motion_blur_config": null, "noise_reduction": null, "path": "", "quality_enhance": null, "time_range": null }, "width": 1920, "height": 1080 } ``` **Audio material:** ```json { "app_id": 0, "category_id": "", "category_name": "local", "check_flag": 63487, "duration": {mp3_duration_ms}, "effect_id": "", "file_Path": "D:/{output_dir}/narration_{clip_id}.mp3", "formula_id": "", "id": "{material_uuid}", "import_time": 1700000000, "import_time_ms": 1700000000000, "item_source": 1, "md5": "", "metetype": "music", "name": "narration_{clip_id}", "roughcut_time_range": { "duration": -1, "start": -1 }, "sub_time_range": { "duration": -1, "start": -1 }, "type": "extract_music" } ``` **Text material:** ```json { "add_type": 0, "alignment": 1, "background_alpha": 0.0, "background_color": "", "background_height": 0.08, "background_horizontal_offset": 0.0, "background_round_radius": 0.0, "background_style": 0, "background_vertical_offset": 0.0, "background_width": 0.14, "bold_width": 0.0, "border_alpha": 1.0, "border_color": "#000000", "border_width": 0.08, "content": "{subtitle_xml}", "fixed_height": -1.0, "fixed_width": -1.0, "font_category_id": "", "font_category_name": "", "font_id": "", "font_name": "", "font_path": "", "font_resource_id": "", "font_size": 60.0, "font_source_platform": 0, "font_time_range": { "duration": 0, "start": 0 }, "font_title": "新标准黑", "font_url": "", "fonts": [], "force_apply_line_max_width": false, "global_alpha": 1.0, "group_id": "", "has_shadow": true, "id": "{material_uuid}", "initial_scale": 1.0, "is_rich_text": false, "italic": false, "italic_degree": 0, "letter_spacing": 0.0, "line_feed": 1, "line_max_width": 0.82, "line_spacing": 0.02, "multi_language_current": "none", "name": "", "original_size": [], "preset_id": "", "recognize_task_id": "", "recognize_type": 0, "relevance_segment": [], "shadow_alpha": 0.9, "shadow_angle": -45.0, "shadow_color": "#000000", "shadow_distance": 8.0, "shadow_point": { "x": 0.6364, "y": -0.6364 }, "shadow_smoothing": 0.45, "shape_clip_x": false, "shape_clip_y": false, "style_name": "", "sub_type": 0, "text_alpha": 1.0, "text_color": "#FFFFFF", "text_curve": null, "text_preset_resource_id": "", "text_size": 60, "text_to_audio_ids": [], "tts_auto_update": false, "type": "text", "typesetting": 0, "underline": false, "underline_offset": 0.22, "underline_width": 0.05, "use_effect_default_color": true, "words": { "end_time": [], "start_time": [], "text": [] } } ``` --- ### Time Conversion Rules ```python def ms(seconds: float) -> int: """Convert seconds to milliseconds (13-digit JianYing timestamp).""" return int(seconds * 1000) # Timeline cursor — accumulates as clips are placed cursor_ms = 0 for clip in clips: clip_duration_ms = ms(clip["mp3_duration"]) clip_source_start = ms(clip["start_time"]) # position IN source video timeline_start = cursor_ms # position IN output timeline cursor_ms += clip_duration_ms # advance cursor ``` --- ### Python Generator Function ```python from scripts.jianying_draft import generate_jianying_draft meta, content = generate_jianying_draft( project_name = project_name, # already conflict-resolved clips = clips, # with mp3_duration + mp4_path + mp3_path output_dir = output_dir, # abs path, used in file_Path fields original_width = 1920, original_height = 1080 ) import json, os os.makedirs(output_dir, exist_ok=True) with open(f"{output_dir}/draft_meta_info.json", "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) with open(f"{output_dir}/draft_content.json", "w", encoding="utf-8") as f: json.dump(content, f, ensure_ascii=False, indent=2) ``` --- ## Step 8: Final Output Message ``` ✅ 生成完成！ 📁 输出目录: video_clipper_output/ 配音文件 (narration_*.mp3): narration_clip_001.mp3 6.34s narration_clip_002.mp3 5.81s ... 共 N 个 视频切片 (clip_*.mp4): clip_001.mp4 6.34s ← 与 narration_clip_001.mp3 精确对齐 clip_002.mp4 5.81s ← 与 narration_clip_002.mp3 精确对齐 ... 共 N 个 字幕文件: subtitles.srt (N 条，时间轴与 mp3 严格对应) 剪映草稿: draft_content.json ``` **首次使用时，额外显示剪映草稿导入指引：** ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📌 剪映草稿导入方法（首次使用） 草稿目录： Windows: C:\Users\{用户名}\AppData\Local\JianyingPro\User Data\Projects\com.lveditor.draft\ macOS: ~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/ 步骤： 1. 在草稿目录下新建一个文件夹（随意命名） 2. 将 draft_content.json 放入该文件夹 3. 打开剪映 → 首页 → 找到刚才的草稿 → 点击「继续编辑」 4. clip_XXX.mp4 已含旁白音频，可直接在剪映时间轴使用 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` 只在以下情况显示导入指引： - 用户第一次运行（对话中无历史记录） - 用户说"不知道怎么导入" / "草稿在哪" --- ## Timing Alignment Rules (CRITICAL) ``` mp3_duration → controls everything downstream │ ├──► clip_XXX.mp4 duration = mp3_duration (ffmpeg -t flag) │ ├──► subtitles.srt timing = cumulative sum of mp3_durations │ clip_001: 00:00:00,000 → 00:00:06,340 │ clip_002: 00:00:06,340 → 00:00:12,160 │ clip_003: 00:00:12,160 → ... │ └──► draft_content.json srt_start / srt_end = same values ``` **Never use source video timestamps for srt. Always use mp3_duration.** --- ## API Keys | Service | Key | Purpose | |---------|-----|---------| | Anthropic Claude | auto (OpenClaw) | LLM narration generation | | Qwen-VL-Plus | `QWEN_API_KEY` env var | Silent scene description (Mode B) | | edge-tts | no key needed | TTS narration MP3 | | Whisper | local, no key | Speech transcription (Mode B) | --- ## Error Handling | Error | Resolution | |-------|-----------| | ffprobe / ffmpeg not found | `apt install ffmpeg` / `brew install ffmpeg` / ffmpeg.org | | TTS fails for a clip | Retry once; if still fails, set `mp3_duration = estimated_seconds`, flag in report | | ffmpeg clip render fails | Log and skip; include in report as failed clips | | LLM narration API error | Fall back to raw event description; rewrite style manually | | Whisper OOM (Mode B) | Switch to `base` model or chunk into 10-min segments | | Qwen-VL-Plus error | Use rule-based fallback narration | --- ## Dependencies ```bash pip install edge-tts openai openai-whisper --break-system-packages # FFmpeg: https://ffmpeg.org/download.html ``` --- ## Scripts - `scripts/build_payload.py` — clip analysis, LLM narration, render_output_video, clips_to_draft - `scripts/narration_audio.py` — edge-tts generation, SRT builder, report - `scripts/whisper_transcribe.py`— Mode B speech transcription - `scripts/run_clip_pipeline.py` — end-to-end CLI

path: references/capcut-mcp.md
content: # CapCut MCP Server Reference

> Protocol: MCP (stdio) | 11 tools available

## Setup

```json
{
  "mcpServers": {
    "capcut-api": {
      "command": "python3.10",
      "args": ["mcp_server.py"],
      "cwd": "/path/to/CapCutAPI-dev",
      "env": {
        "PYTHONPATH": "/path/to/CapCutAPI-dev"
      }
    }
  }
}
```

## Tool Reference

### create_draft
Create a new video project. Returns `draft_id`.
```json
{"width": 1920, "height": 1080}
```

### get_video_duration
Get the duration of a video file in seconds.
```json
{"video_url": "/absolute/path/to/video.mp4"}
```

### add_video
Add a video segment to the timeline.
```json
{
  "draft_id": "dfd_cat_xxx",
  "video_url": "/path/to/video.mp4",
  "start": 12.5,
  "end": 28.3,
  "volume": 1.0
}
```
Set `volume: 0.0` to mute (for silent segments where narration replaces audio).
Set `volume: 1.0` to keep original audio (`keep_original_audio: true`).

### add_audio
Add narration or background audio.
```json
{
  "draft_id": "dfd_cat_xxx",
  "audio_url": "/path/to/narration_001.mp3",
  "start": 4.2,
  "end": 6.2,
  "volume": 1.0
}
```

### add_subtitle
Add an SRT subtitle file to the project.
```json
{
  "draft_id": "dfd_cat_xxx",
  "srt_path": "/path/to/subtitles.srt",
  "font_style": "default",
  "position": "bottom"
}
```

### add_text
Add a text overlay (used for narration cards on silent segments).
```json
{
  "draft_id": "dfd_cat_xxx",
  "text": "[Alex walks to the whiteboard]",
  "start": 4.2,
  "end": 6.2,
  "font_size": 36,
  "font_color": "#FFFFFF",
  "shadow_enabled": true,
  "shadow_color": "#000000",
  "shadow_alpha": 0.8,
  "background_color": "#1E1E1E",
  "background_alpha": 0.6,
  "background_round_radius": 10
}
```

### add_effect
Add visual effects to a segment.
```json
{
  "draft_id": "dfd_cat_xxx",
  "effect_type": "fade_in",
  "parameters": {},
  "duration": 0.3
}
```

### add_image / add_sticker / add_video_keyframe
See the CapCut MCP server documentation for full parameter reference.

### save_draft
Finalize and save the project. Must be called last.
```json
{"draft_id": "dfd_cat_xxx"}
```

## Return Format
```json
{
  "success": true,
  "result": {
    "draft_id": "dfd_cat_xxx",
    "draft_url": "https://..."
  }
}
```


path: references/vectcut-api.md
content: # vectcut-api Reference

> Version: v1.0 | Default address: http://localhost:8765

---

## Starting the Service

```bash
pip install vectcut-api --break-system-packages
vectcut-api serve --port 8765
# Verify: curl http://localhost:8765/health
```

---

## POST /api/v1/generate — Generate CapCut Draft

### Request Body

```json
{
  "project_name": "My Clip Project",
  "video_path": "/absolute/path/to/video.mp4",
  "clips": [
    {
      "clip_id": "clip_001",
      "start_time": 12.5,
      "end_time": 28.3,
      "subtitles": [
        {"start": 12.5, "end": 15.2, "text": "First line of dialogue"},
        {"start": 15.2, "end": 18.0, "text": "Second line of dialogue"}
      ]
    }
  ],
  "options": {
    "subtitle_style": "default",
    "subtitle_position": "bottom",
    "font_size": 36,
    "transition": "none",
    "bgm_path": null,
    "bgm_volume": 0.3,
    "output_resolution": "1080p",
    "output_format": "draft_json"
  }
}
```

### Enum Values

**subtitle_style:** `default` (white text, black outline) | `highlight` (yellow background) | `card` (semi-transparent black box) | `none`

**subtitle_position:** `bottom` | `top` | `center`

**transition:** `none` | `fade` | `slide_left` | `slide_right` | `zoom`

**output_resolution:** `1080p` (1920×1080) | `720p` | `portrait_1080p` (1080×1920 vertical, TikTok-ready) | `portrait_720p`

**output_format:** `draft_json` (CapCut) | `premiere_xml` (Adobe Premiere)

### Success Response

```json
{
  "success": true,
  "draft_path": "/tmp/vectcut_output/draft_content.json",
  "draft_content": { "...CapCut draft object..." },
  "meta": {
    "clip_count": 5,
    "total_duration": 222.5,
    "subtitle_count": 38
  }
}
```

### Error Response

```json
{
  "success": false,
  "error_code": "VIDEO_NOT_FOUND",
  "error_message": "Video file does not exist"
}
```

---

## GET /api/v1/health

```json
{"status": "ok", "version": "1.0.0"}
```

## GET /api/v1/styles

Returns all available subtitle styles with preview image URLs.

---

## Python Helper Function

```python
import requests, json
from pathlib import Path

def call_vectcut_api(video_path, clips, project_name="My Project",
                     subtitle_style="default", transition="none",
                     output_resolution="1080p", api_base="http://localhost:8765"):
    # Health check
    try:
        r = requests.get(f"{api_base}/api/v1/health", timeout=5)
        assert r.json()["status"] == "ok"
    except Exception as e:
        raise RuntimeError(f"vectcut-api is unavailable. Please start the service first: {e}")

    payload = {
        "project_name": project_name,
        "video_path": str(Path(video_path).resolve()),
        "clips": clips,
        "options": {
            "subtitle_style": subtitle_style,
            "subtitle_position": "bottom",
            "font_size": 36,
            "transition": transition,
            "bgm_path": None,
            "output_resolution": output_resolution,
            "output_format": "draft_json"
        }
    }

    resp = requests.post(f"{api_base}/api/v1/generate", json=payload, timeout=60)
    resp.raise_for_status()
    result = resp.json()

    if not result["success"]:
        raise RuntimeError(f"{result['error_code']}: {result['error_message']}")

    out = "draft_content.json"
    with open(out, "w", encoding="utf-8") as f:
        json.dump(result["draft_content"], f, ensure_ascii=False, indent=2)

    return result, out
```

---

## Error Codes

| error_code | Meaning | Resolution |
|-----------|---------|-----------|
| `VIDEO_NOT_FOUND` | Video path does not exist | Check the absolute path |
| `INVALID_CLIP_TIME` | Timestamp exceeds video duration | Verify start/end values |
| `UNSUPPORTED_FORMAT` | Unsupported video format | Convert to mp4 with ffmpeg |
| `SUBTITLE_OVERLAP` | Subtitle timestamps overlap | Check segment merge logic |
| `SERVICE_ERROR` | Internal service error | Check vectcut-api logs |


path: scripts/build_payload.py
content: #!/usr/bin/env python3
"""
build_payload.py
Core clip analysis:
  - Importance scoring (0.0-1.0) per dialogue segment
  - Koubo (jump-cut) filler/pause removal
  - Silent segment detection
  - Qwen-VL-Plus visual narration (with rule-based fallback)
  - 90-second budget enforcement
  - vectcut-api payload builder
"""
import base64
import json
import os
import re
import subprocess
import tempfile
from math import gcd
from pathlib import Path

# ── API Keys ───────────────────────────────────────────────────────────────────
# Set your keys here. env vars are used as fallback if left as None.
QWEN_API_KEY = None   # DashScope key for Qwen-VL-Plus, e.g. "sk-xxxxxxxxxxxx"
               # fallback: os.environ.get("QWEN_API_KEY")

# ── Constants ──────────────────────────────────────────────────────────────────
MAX_OUTPUT_SECONDS   = 90
MIN_SILENCE_GAP      = 1.5   # gap between Whisper segments to flag as silent
KOUBO_PAUSE_THRESH   = 0.5   # pause longer than this is cut within a sentence
MIN_CLIP_DURATION    = 0.8   # seconds
MAX_CLIP_DURATION    = 5.0   # seconds (per individual sub-cut)

FILLER_WORDS = {
    "um", "uh", "like", "you know", "so", "right", "okay",
    "well", "basically", "literally", "actually", "i mean",
    "kind of", "sort of"
}
HIGH_IMPORTANCE_WORDS = {
    "announced", "confirmed", "revealed", "breaking", "never", "always",
    "important", "critical", "secret", "truth", "finally", "shocked",
    "incredible", "amazing", "warning", "must", "key", "proven"
}


# ── Character map ──────────────────────────────────────────────────────────────
def build_character_map(characters: list) -> dict:
    return {c["id"]: c["name"] for c in (characters or [])}


# ── Importance scoring (0.0 – 1.0) ───────────────────────────────────────────
def score_segment(segment: dict) -> float:
    """
    Score a spoken segment for editorial importance.
    > 0.7 → keep full + preserve audio
    0.4-0.7 → trim with koubo cuts
    < 0.4 → drop or heavily cut
    """
    text = segment.get("text", "").lower()
    score = 0.4  # baseline

    # High-importance keywords
    hits = sum(1 for w in HIGH_IMPORTANCE_WORDS if w in text)
    score += min(hits * 0.1, 0.3)

    # Emotional punctuation
    if text.rstrip().endswith("!"):
        score += 0.15
    if text.rstrip().endswith("?"):
        score += 0.05

    # Duration sweet spot (3–8s feels most natural)
    dur = segment["end"] - segment["start"]
    if 3.0 <= dur <= 8.0:
        score += 0.1
    elif dur < 1.5:
        score -= 0.15  # too short, likely noise

    # Cap to 0.0 – 1.0
    return round(max(0.0, min(1.0, score)), 3)


# ── Silence detection ──────────────────────────────────────────────────────────
def detect_silent_segments(segments: list, video_duration: float = 0) -> list:
    enriched = []
    prev_end = 0.0

    for seg in segments:
        gap = seg["start"] - prev_end
        if gap > MIN_SILENCE_GAP:
            enriched.append({
                "id": f"silent_{len(enriched)}",
                "start": prev_end, "end": seg["start"],
                "text": "", "is_silent": True,
                "silent_duration": round(gap, 3),
                "speaker_id": None, "speaker_name": None
            })
        enriched.append({**seg, "is_silent": False})
        prev_end = seg["end"]

    if video_duration and video_duration - prev_end > MIN_SILENCE_GAP:
        enriched.append({
            "id": "silent_end",
            "start": prev_end, "end": video_duration,
            "text": "", "is_silent": True,
            "silent_duration": round(video_duration - prev_end, 3),
            "speaker_id": None, "speaker_name": None
        })
    return enriched


# ── Koubo (jump-cut) analysis ─────────────────────────────────────────────────
def apply_koubo_cuts(segment: dict) -> list:
    """Return list of micro-cuts to apply within the segment."""
    cuts = []
    text = segment.get("text", "").lower()
    words = text.split()
    if not words:
        return cuts

    seg_start = segment["start"]
    seg_dur = segment["end"] - seg_start
    time_per_word = seg_dur / len(words)
    cursor = seg_start

    for word in words:
        clean = re.sub(r"[^a-z ]", "", word).strip()
        if clean in FILLER_WORDS:
            cuts.append({
                "remove_start": round(cursor, 3),
                "remove_end": round(cursor + time_per_word * 1.2, 3),
                "reason": f"filler: '{clean}'"
            })
        cursor += time_per_word

    return cuts


# ── Qwen-VL-Plus visual narration ─────────────────────────────────────────────
# DashScope OpenAI-compatible endpoint
QWEN_VL_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
QWEN_VL_MODEL    = "qwen-vl-plus"


def describe_frame_qwen(frame_path: str, context_before: str,
                         context_after: str, api_key: str) -> str:
    """
    Call Qwen-VL-Plus (DashScope) to describe a video frame.
    Uses the OpenAI-compatible API — no extra SDK needed beyond openai.
    Returns a 1-2 sentence narration string.
    """
    try:
        from openai import OpenAI
    except ImportError:
        import subprocess, sys
        subprocess.run([sys.executable, "-m", "pip", "install", "openai",
                        "--break-system-packages", "-q"], check=True)
        from openai import OpenAI

    with open(frame_path, "rb") as f:
        img_b64 = base64.b64encode(f.read()).decode("utf-8")

    client = OpenAI([REDACTED_SECRET], base_url=QWEN_VL_BASE_URL)
    prompt = (
        f"Describe what is happening in this video frame in 1-2 sentences (max 15 words). "
        f"Context before this scene: \"{context_before[-80:]}\". "
        f"Context after: \"{context_after[:80]}\". "
        f"Be specific about visible actions, expressions, and objects."
    )
    response = client.chat.completions.create(
        model=QWEN_VL_MODEL,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}},
                {"type": "text", "text": prompt}
            ]
        }]
    )
    return response.choices[0].message.content.strip()


def extract_frame(video_path: str, timestamp: float, output_path: str):
    """Extract a single frame from the video at `timestamp` seconds."""
    subprocess.run([
        "ffmpeg", "-y", "-ss", str(timestamp),
        "-i", video_path, "-vframes", "1",
        "-q:v", "2", output_path
    ], capture_output=True, check=True)


def generate_narration(silent_seg: dict, context_before: str, context_after: str,
                        video_path: str = None) -> str:
    """
    Generate narration for a silent segment.
    Uses Qwen-VL-Plus (global QWEN_API_KEY) if video_path is provided.
    Falls back to rule-based otherwise.
    """
    resolved_key = QWEN_API_KEY or os.environ.get('QWEN_API_KEY')
    if video_path and resolved_key:
        try:
            midpoint = (silent_seg["start"] + silent_seg["end"]) / 2
            with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
                frame_path = tmp.name
            extract_frame(video_path, midpoint, frame_path)
            narration = describe_frame_qwen(frame_path, context_before,
                                            context_after, resolved_key)
            Path(frame_path).unlink(missing_ok=True)
            return narration
        except Exception as e:
            print(f"  Qwen-VL-Plus failed ({e}), falling back to rule-based.")

    # Rule-based fallback
    duration = silent_seg.get("silent_duration", 0)
    if duration < 3:
        last = context_before.strip().split()[-6:]
        return f"[Pause after: '{' '.join(last)}']" if last else "[Beat]"
    elif duration < 8:
        first = context_after.strip().split()[:6]
        return f"[Scene transition before: '{' '.join(first)}']" if first else "[Scene transition]"
    return "[Extended scene — describe the action on screen]"


# ── 90-second budget enforcement ──────────────────────────────────────────────
def enforce_budget(clips: list, max_seconds: float = MAX_OUTPUT_SECONDS) -> tuple:
    def priority(c):
        s = c.get("importance_score", 0.0)
        if c.get("keep_original_audio"):
            s += 0.2
        if c.get("type") == "dialogue":
            s += 0.1
        return s

    # Sort by score descending, then re-select in timeline order
    by_score = sorted(clips, key=priority, reverse=True)
    selected_ids = set()
    total = 0.0

    for clip in by_score:
        dur = clip["end_time"] - clip["start_time"]
        if total + dur <= max_seconds:
            selected_ids.add(clip["clip_id"])
            total += dur

    selected = sorted([c for c in clips if c["clip_id"] in selected_ids],
                      key=lambda c: c["start_time"])
    dropped = len(clips) - len(selected)
    return selected, round(total, 1), dropped


# ── Full clip builder ──────────────────────────────────────────────────────────
def build_clips(segments: list, characters: list = None,
                video_duration: float = 0, video_path: str = None) -> tuple:
    """
    Main function. Returns (selected_clips, total_duration, budget_status).
    Uses global QWEN_API_KEY for visual narration.
    """
    char_map = build_character_map(characters)
    enriched = detect_silent_segments(segments, video_duration)

    clips = []
    counter = 1
    prev_text = ""

    for i, seg in enumerate(enriched):
        next_text = enriched[i + 1]["text"] if i + 1 < len(enriched) else ""

        if seg["is_silent"]:
            narration = generate_narration(
                seg, prev_text, next_text, video_path
            )
            clips.append({
                "clip_id": f"clip_{counter:03d}",
                "start_time": round(seg["start"], 3),
                "end_time": round(seg["end"], 3),
                "duration": round(seg["end"] - seg["start"], 3),
                "type": "silent",
                "speaker_name": None,
                "importance_score": 0.3,
                "transcript": "",
                "keep_original_audio": False,
                "koubo_cuts": [],
                "narration_text": narration,
                "subtitles": []
            })
        else:
            speaker = char_map.get(seg.get("speaker_id", ""),
                                   seg.get("speaker_name") or "Unknown")
            score = score_segment(seg)
            koubo = apply_koubo_cuts(seg) if score >= 0.4 else []
            keep_audio = score > 0.7

            clips.append({
                "clip_id": f"clip_{counter:03d}",
                "start_time": round(seg["start"], 3),
                "end_time": round(seg["end"], 3),
                "duration": round(seg["end"] - seg["start"], 3),
                "type": "dialogue",
                "speaker_name": speaker,
                "importance_score": score,
                "transcript": seg["text"],
                "keep_original_audio": keep_audio,
                "koubo_cuts": koubo,
                "narration_text": None,
                "subtitles": [{
                    "start": seg["start"],
                    "end": seg["end"],
                    "text": seg["text"],
                    "speaker": speaker
                }]
            })
            prev_text = seg["text"]

        counter += 1

    selected, total, dropped = enforce_budget(clips)
    status = {
        "original_count": len(clips),
        "selected_count": len(selected),
        "dropped_count": dropped,
        "total_duration": total,
        "within_budget": total <= MAX_OUTPUT_SECONDS
    }
    return selected, total, status


# ── vectcut-api payload builder ────────────────────────────────────────────────
def clips_to_draft(video_path: str, clips: list,
                   original_width: int, original_height: int,
                   project_name: str = "Movie Clip Edit") -> dict:
    """
    Build the draft_content.json structure for manual import into CapCut.
    Save with: json.dump(draft, open('draft_content.json','w'), ensure_ascii=False, indent=2)
    """
    return {
        "project_name": project_name,
        "video_path": str(Path(video_path).resolve()),
        "aspect_ratio": {
            "width": original_width,
            "height": original_height,
            "mode": "preserve"
        },
        "editing_style": "koubo",
        "max_duration": MAX_OUTPUT_SECONDS,
        "clips": [{
            "clip_id": c["clip_id"],
            "start_time": c["start_time"],
            "end_time": c["end_time"],
            "type": c["type"],
            "speaker_name": c.get("speaker_name"),
            "keep_original_audio": c.get("keep_original_audio", False),
            "koubo_cuts": c.get("koubo_cuts", []),
            "narration_text": c.get("narration_text"),
            "subtitles": c.get("subtitles", [])
        } for c in clips],
        "options": {
            "subtitle_style": "default",
            "subtitle_position": "bottom",
            "font_size": 36,
            "transition": "none",
            "output_format": "draft_json"
        }
    }


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    test_segs = [
        {"id": 0, "start": 0.0,  "end": 4.5,
         "text": "Welcome back everyone, um, today we have a really important announcement."},
        {"id": 1, "start": 7.0,  "end": 13.0,
         "text": "Sarah has confirmed — we never expected numbers like this!"},
        {"id": 2, "start": 13.2, "end": 18.0,
         "text": "Let me basically, like, show you what I mean."},
        {"id": 3, "start": 26.0, "end": 30.0,
         "text": "This is the key breakthrough that shocked the industry."},
    ]
    chars = [
        {"id": "spk_0", "name": "Alex",  "role": "Host"},
        {"id": "spk_1", "name": "Sarah", "role": "Guest"}
    ]

    clips, total, status = build_clips(test_segs, chars, video_duration=32.0)

    print(f"Status : {status}")
    print(f"Total  : {total}s\n")
    for c in clips:
        icon = "🎙" if c["type"] == "dialogue" else "🔇"
        print(f"{icon} [{c['clip_id']}] {c['start_time']:.1f}–{c['end_time']:.1f}s  "
              f"score={c['importance_score']}  audio={c['keep_original_audio']}")
        if c["transcript"]:
            print(f"   text  : {c['transcript'][:70]}")
        if c["narration_text"]:
            print(f"   narr  : {c['narration_text']}")
        for cut in c.get("koubo_cuts", []):
            print(f"   ✂ {cut['reason']}")


# ── MP4 render ─────────────────────────────────────────────────────────────────
def render_output_video(clips: list, video_path: str, output_dir: str,
                         original_width: int, original_height: int) -> str:
    """
    Render final output_video.mp4 by:
      - Extracting each clip from source video
      - Overlaying narration audio (muting original) for narration clips
      - Keeping original audio for keep_original_audio=True clips
      - Concatenating everything into one MP4
      - Preserving original aspect ratio
    """
    import shutil
    tmp_dir = os.path.join(output_dir, "tmp_clips")
    os.makedirs(tmp_dir, exist_ok=True)
    clip_files = []

    for clip in clips:
        clip_path = os.path.join(tmp_dir, f"{clip['clip_id']}.mp4")
        duration  = clip["end_time"] - clip["start_time"]
        audio_path = clip.get("audio_path")

        if audio_path and Path(audio_path).exists():
            # Narration clip: overlay TTS audio, mute original
            cmd = [
                "ffmpeg", "-y",
                "-ss", str(clip["start_time"]), "-t", str(duration),
                "-i", video_path,
                "-i", audio_path,
                "-map", "0:v:0", "-map", "1:a:0",
                "-c:v", "libx264", "-c:a", "aac",
                "-shortest", clip_path
            ]
        elif clip.get("keep_original_audio"):
            # Dialogue clip: keep original audio
            cmd = [
                "ffmpeg", "-y",
                "-ss", str(clip["start_time"]), "-t", str(duration),
                "-i", video_path,
                "-c:v", "libx264", "-c:a", "aac",
                clip_path
            ]
        else:
            # Silent clip with no narration audio: mute
            cmd = [
                "ffmpeg", "-y",
                "-ss", str(clip["start_time"]), "-t", str(duration),
                "-i", video_path,
                "-c:v", "libx264", "-an",
                clip_path
            ]

        result = subprocess.run(cmd, capture_output=True)
        if result.returncode != 0:
            print(f"  Warning: clip {clip['clip_id']} render failed, skipping.")
            continue
        clip_files.append(clip_path)

    if not clip_files:
        raise RuntimeError("No clips rendered successfully.")

    # Concat list
    concat_path = os.path.join(tmp_dir, "concat.txt")
    with open(concat_path, "w") as f:
        for p in clip_files:
            f.write(f"file '{os.path.abspath(p)}'\n")

    output_path = os.path.join(output_dir, "output_video.mp4")
    subprocess.run([
        "ffmpeg", "-y",
        "-f", "concat", "-safe", "0", "-i", concat_path,
        "-vf", f"scale={original_width}:{original_height}"
                ":force_original_aspect_ratio=decrease"
                ":flags=lanczos",
        "-c:v", "libx264", "-preset", "fast",
        "-c:a", "aac", "-b:a", "192k",
        "-movflags", "+faststart",
        output_path
    ], check=True)

    shutil.rmtree(tmp_dir, ignore_errors=True)
    print(f"[render] Output video: {output_path}")
    return output_path


# ── LLM cinematic narration generator ─────────────────────────────────────────
def generate_cinematic_narration(
    event_description: str,
    stage: str,
    movie_name: str,
    characters: list = None,
    prev_narration: str = "",
    is_first: bool = False
) -> str:
    """
    Call Claude via Anthropic API to generate cinematic movie narration.
    Uses the gripping style: hook opening, tension-building, cliffhanger endings.

    Falls back to event_description if API call fails.
    """
    try:
        import urllib.request, json as _json

        char_str = "、".join(
            f"{c['name']}（{c.get('role','角色')}）"
            for c in (characters or [])
        ) or "未提供"

        hook_instruction = (
            "【重要】这是第一段旁白，必须以钩子句型开头，例如："
            "「眼前这个{角色}，只是做了个{小事}，才...」"
            if is_first else ""
        )

        prompt = f"""你是一位专业的电影解说文案写手，风格参考"木鱼水心"、"谷阿莫"、"电影最TOP"。

电影名称：{movie_name}
角色信息：{char_str}
当前情节事件：{event_description}
所处阶段：{stage}（起因/发展/高潮/结局）
上一段旁白：{prev_narration or "（无）"}
{hook_instruction}

要求：
1. 字数：20-35字，朗读时长约6-8秒
2. 风格：极具感染力，制造悬念，让观众迫切想看下去
3. 开头（仅第一段）：必须用钩子句型，如「眼前这个XX只是做了个XX，才...」
4. 中间段：用「随后」「然而」「就在此时」等转折词自然衔接
5. 结尾段：留悬念或给出有力总结
6. 禁止：平铺直叙、「我们看到」「画面中」「镜头转向」

只输出旁白文案，不要任何解释。"""

        payload = _json.dumps({
            "model": "claude-sonnet-4-20250514",
            "max_tokens": 1000,
            "messages": [{"role": "user", "content": prompt}]
        }).encode()

        req = urllib.request.Request(
            "https://api.anthropic.com/v1/messages",
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST"
        )
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = _json.loads(resp.read())
            return data["content"][0]["text"].strip()

    except Exception as e:
        print(f"  LLM narration failed ({e}), using event description as fallback.")
        return event_description


# ── Project name conflict detection ───────────────────────────────────────────
JIANYING_DRAFT_DIRS = [
    os.path.join(os.environ.get("LOCALAPPDATA", ""), 
                 "JianyingPro", "User Data", "Projects", "com.lveditor.draft"),
    os.path.expanduser(
        "~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft"),
]

def resolve_project_name(base_name: str) -> str:
    """
    Check JianYing draft directory for name conflicts.
    Returns base_name if no conflict, or base_name(1) / base_name(2) / ... if taken.
    Notifies user if a rename occurs.
    """
    draft_dir = next((d for d in JIANYING_DRAFT_DIRS if os.path.isdir(d)), None)
    if not draft_dir:
        return base_name  # draft dir not found, proceed without checking

    existing = set(os.listdir(draft_dir))
    if base_name not in existing:
        return base_name

    i = 1
    while f"{base_name}({i})" in existing:
        i += 1
    resolved = f"{base_name}({i})"
    print(f"\n⚠️  草稿名称「{base_name}」已存在")
    print(f"   自动重命名为「{resolved}」以避免覆盖\n")
    return resolved


# ── Narration word budget calculator ──────────────────────────────────────────
CHARS_PER_MINUTE        = 200   # 1 min output video = 200 Chinese chars
TARGET_OUTPUT_SECS      = 60    # target output duration
DURATION_RANGE_SECS     = (45, 75)   # acceptable output range
IDEAL_CHARS_PER_SEGMENT = 25    # ~7.5s TTS per segment


def calculate_narration_budget(video_duration_seconds: float) -> dict:
    """
    Calculate narration word count budget.
    Output video always targets ~60 seconds (45–75s), regardless of source length.
    Rule: 1 minute output = 200 Chinese characters.
    """
    target_chars = CHARS_PER_MINUTE                                         # 200
    min_chars    = int(CHARS_PER_MINUTE * DURATION_RANGE_SECS[0] / 60)     # 150
    max_chars    = int(CHARS_PER_MINUTE * DURATION_RANGE_SECS[1] / 60)     # 250
    ideal_segs   = round(target_chars / IDEAL_CHARS_PER_SEGMENT)            # ~8

    return {
        "target_chars":         target_chars,
        "min_chars":            min_chars,
        "max_chars":            max_chars,
        "ideal_segment_count":  ideal_segs,
        "chars_per_segment":    IDEAL_CHARS_PER_SEGMENT,
        "target_output_secs":   TARGET_OUTPUT_SECS,
        "source_duration_secs": video_duration_seconds,
    }


def check_narration_budget(narrations: list, budget: dict) -> dict:
    """
    Count total chars across all narration segments and check against budget.
    Returns status dict with pass/fail and corrective action.
    """
    total_chars = sum(len(n.get("narration_text", "")) for n in narrations)
    status = "ok"
    action = None

    if total_chars < budget["min_chars"]:
        status = "too_short"
        action = f"expand: need {budget['min_chars'] - total_chars} more chars"
    elif total_chars > budget["max_chars"]:
        status = "too_long"
        action = f"trim: remove {total_chars - budget['max_chars']} chars"

    return {
        "total_chars":    total_chars,
        "target_chars":   budget["target_chars"],
        "min_chars":      budget["min_chars"],
        "max_chars":      budget["max_chars"],
        "status":         status,
        "action":         action,
        "within_budget":  status == "ok",
    }


path: scripts/capcut_mcp_builder.py
content: #!/usr/bin/env python3
"""
CapCut MCP Builder
Translates the clip list from build_payload.py into a sequence of
CapCut MCP tool calls to construct the final draft.

Replaces the old vectcut-api HTTP approach entirely.

Usage (called by Claude as part of the pipeline):
  from capcut_mcp_builder import build_capcut_draft
  result = build_capcut_draft(mcp_client, clips, video_path, original_width, original_height, srt_path, narration_dir)
"""

import os
from pathlib import Path


# ── Aspect ratio helpers ───────────────────────────────────────────────────────
def get_capcut_dimensions(original_width: int, original_height: int) -> tuple:
    """
    Return (width, height) for create_draft, preserving original aspect ratio.
    Normalizes to a standard resolution while keeping the exact ratio.
    """
    if not original_width or not original_height:
        return 1920, 1080  # safe default

    # Common output targets
    targets = [
        (1920, 1080),  # 16:9 landscape
        (1080, 1920),  # 9:16 portrait
        (1080, 1080),  # 1:1 square
        (2560, 1080),  # 21:9 ultrawide
        (1080, 1350),  # 4:5 Instagram
    ]

    input_ratio = original_width / original_height
    best = min(targets, key=lambda t: abs(t[0] / t[1] - input_ratio))

    # If no target is close enough, use the exact original dimensions
    best_ratio = best[0] / best[1]
    if abs(best_ratio - input_ratio) > 0.05:
        return original_width, original_height

    return best


# ── Main draft builder ─────────────────────────────────────────────────────────
def build_capcut_draft(
    mcp_client,              # MCP client instance from OpenClaw/Claude context
    clips: list,             # output of build_payload.build_clips()
    video_path: str,         # absolute path to source video
    original_width: int,
    original_height: int,
    srt_path: str = None,    # path to subtitles.srt (from narration_audio.generate_srt)
    narration_dir: str = None,  # dir containing clip_XXX_narration.mp3 files
    project_name: str = "Movie Clip Edit"
) -> dict:
    """
    Build a complete CapCut draft via MCP tool calls.

    Steps:
      1. create_draft  — set dimensions
      2. add_video     — one call per clip (with volume based on keep_original_audio)
      3. add_audio     — one call per silent clip that has a narration MP3
      4. add_text      — one call per silent clip for the on-screen narration card
      5. add_subtitle  — one call to attach the full SRT file
      6. save_draft    — finalize

    Returns the save_draft result dict.
    """
    video_path = str(Path(video_path).resolve())
    w, h = get_capcut_dimensions(original_width, original_height)

    print(f"\n[MCP] Starting CapCut draft: {w}x{h} | {len(clips)} clips")

    # ── Step 1: Create draft ───────────────────────────────────────────────────
    result = mcp_client.call_tool("create_draft", {"width": w, "height": h})
    if not result.get("success"):
        raise RuntimeError(f"create_draft failed: {result}")
    draft_id = result["result"]["draft_id"]
    print(f"[MCP] Draft created: {draft_id}")

    # ── Step 2: Add video clips ────────────────────────────────────────────────
    for clip in clips:
        # For koubo editing: apply internal micro-cuts by splitting into
        # sub-segments around each koubo_cut removal window
        sub_segments = _apply_koubo_cuts(clip)

        for seg in sub_segments:
            volume = 1.0 if clip.get("keep_original_audio", False) else 0.0
            result = mcp_client.call_tool("add_video", {
                "draft_id": draft_id,
                "video_url": video_path,
                "start": seg["start"],
                "end": seg["end"],
                "volume": volume
            })
            if not result.get("success"):
                print(f"[MCP] Warning: add_video failed for {clip['clip_id']} "
                      f"[{seg['start']:.2f}-{seg['end']:.2f}]: {result}")

    print(f"[MCP] Added {len(clips)} clips to timeline")

    # ── Step 3: Add narration audio for silent clips ───────────────────────────
    if narration_dir:
        narration_dir = Path(narration_dir)
        for clip in clips:
            if clip.get("type") != "silent":
                continue
            mp3_path = narration_dir / f"{clip['clip_id']}_narration.mp3"
            if mp3_path.exists():
                result = mcp_client.call_tool("add_audio", {
                    "draft_id": draft_id,
                    "audio_url": str(mp3_path),
                    "start": clip["start_time"],
                    "end": clip["end_time"],
                    "volume": 1.0
                })
                if result.get("success"):
                    print(f"[MCP] Narration audio added: {clip['clip_id']}")
                else:
                    print(f"[MCP] Warning: add_audio failed for {clip['clip_id']}: {result}")

    # ── Step 4: Add narration text cards for silent clips ─────────────────────
    for clip in clips:
        if clip.get("type") != "silent":
            continue
        narration = clip.get("narration_text")
        if not narration:
            continue

        result = mcp_client.call_tool("add_text", {
            "draft_id": draft_id,
            "text": narration,
            "start": clip["start_time"],
            "end": clip["end_time"],
            "font_size": 36,
            "font_color": "#FFFFFF",
            "shadow_enabled": True,
            "shadow_color": "#000000",
            "shadow_alpha": 0.8,
            "background_color": "#1E1E1E",
            "background_alpha": 0.6,
            "background_round_radius": 10
        })
        if result.get("success"):
            print(f"[MCP] Narration text card added: {clip['clip_id']} → '{narration[:40]}'")
        else:
            print(f"[MCP] Warning: add_text failed for {clip['clip_id']}: {result}")

    # ── Step 5: Add subtitle SRT ───────────────────────────────────────────────
    if srt_path and Path(srt_path).exists():
        result = mcp_client.call_tool("add_subtitle", {
            "draft_id": draft_id,
            "srt_path": str(Path(srt_path).resolve()),
            "font_style": "default",
            "position": "bottom"
        })
        if result.get("success"):
            print(f"[MCP] Subtitles added from: {srt_path}")
        else:
            print(f"[MCP] Warning: add_subtitle failed: {result}")

    # ── Step 6: Save draft ─────────────────────────────────────────────────────
    result = mcp_client.call_tool("save_draft", {"draft_id": draft_id})
    if not result.get("success"):
        raise RuntimeError(f"save_draft failed: {result}")

    print(f"\n[MCP] Draft saved successfully!")
    print(f"  draft_id  : {draft_id}")
    print(f"  draft_url : {result['result'].get('draft_url', 'N/A')}")

    return {
        "draft_id": draft_id,
        "draft_url": result["result"].get("draft_url"),
        "dimensions": f"{w}x{h}",
        "clip_count": len(clips),
        "success": True
    }


# ── Koubo micro-cut splitter ───────────────────────────────────────────────────
def _apply_koubo_cuts(clip: dict) -> list:
    """
    Split a clip into sub-segments by removing koubo_cut windows.

    Example:
      clip: start=0.0, end=10.0
      koubo_cuts: [{remove_start: 2.0, remove_end: 2.5},
                   {remove_start: 6.0, remove_end: 6.8}]
      result: [{start:0.0, end:2.0}, {start:2.5, end:6.0}, {start:6.8, end:10.0}]
    """
    cuts = clip.get("koubo_cuts", [])
    start = clip["start_time"]
    end = clip["end_time"]
    min_duration = 0.3  # skip sub-segments shorter than this

    if not cuts:
        return [{"start": start, "end": end}]

    # Sort cuts by start time
    cuts = sorted(cuts, key=lambda c: c["remove_start"])

    segments = []
    cursor = start

    for cut in cuts:
        rs = max(cut["remove_start"], cursor)
        re = min(cut["remove_end"], end)
        if rs > cursor and (rs - cursor) >= min_duration:
            segments.append({"start": cursor, "end": rs})
        cursor = re

    # Add final segment after last cut
    if end - cursor >= min_duration:
        segments.append({"start": cursor, "end": end})

    return segments if segments else [{"start": start, "end": end}]


# ── MCP call sequence preview (for user approval) ─────────────────────────────
def preview_mcp_calls(
    clips: list,
    video_path: str,
    original_width: int,
    original_height: int,
    srt_path: str = None,
    narration_dir: str = None
) -> str:
    """
    Generate a human-readable preview of the MCP tool calls that will be made.
    Show this to the user BEFORE actually executing, as part of Step 4 approval.
    """
    w, h = get_capcut_dimensions(original_width, original_height)
    lines = []
    lines.append(f"MCP Call Plan — {len(clips)} clips → {w}x{h} draft")
    lines.append("=" * 55)

    total_duration = sum(c["end_time"] - c["start_time"] for c in clips)
    dialogue = [c for c in clips if c.get("type") == "dialogue"]
    silent = [c for c in clips if c.get("type") == "silent"]

    lines.append(f"1. create_draft(width={w}, height={h})")
    lines.append("")

    call_n = 2
    for clip in clips:
        subs = _apply_koubo_cuts(clip)
        vol = 1.0 if clip.get("keep_original_audio") else 0.0
        speaker = clip.get("speaker_name") or "—"
        for sub in subs:
            lines.append(
                f"{call_n}. add_video(start={sub['start']:.1f}, end={sub['end']:.1f}, "
                f"volume={vol})  [{clip['clip_id']} {clip.get('type','?')} | {speaker}]"
            )
            call_n += 1

    lines.append("")
    if narration_dir:
        for clip in [c for c in clips if c.get("type") == "silent"]:
            mp3 = Path(narration_dir) / f"{clip['clip_id']}_narration.mp3"
            if mp3.exists():
                lines.append(
                    f"{call_n}. add_audio({clip['clip_id']}_narration.mp3, "
                    f"start={clip['start_time']:.1f}, end={clip['end_time']:.1f})"
                )
                call_n += 1

    lines.append("")
    for clip in [c for c in clips if c.get("type") == "silent" and c.get("narration_text")]:
        lines.append(
            f"{call_n}. add_text('{clip['narration_text'][:35]}...', "
            f"start={clip['start_time']:.1f}, end={clip['end_time']:.1f})"
        )
        call_n += 1

    lines.append("")
    if srt_path:
        lines.append(f"{call_n}. add_subtitle(subtitles.srt, position=bottom)")
        call_n += 1

    lines.append(f"{call_n}. save_draft()")
    lines.append("")
    lines.append(f"Summary: {len(dialogue)} dialogue + {len(silent)} silent clips")
    lines.append(f"Total duration: {total_duration:.1f}s / 90s max")
    lines.append(f"Total MCP calls: {call_n}")

    return "\n".join(lines)


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    test_clips = [
        {
            "clip_id": "clip_001", "type": "dialogue",
            "start_time": 0.0, "end_time": 4.2,
            "keep_original_audio": True,
            "speaker_name": "Alex",
            "narration_text": None,
            "koubo_cuts": [
                {"remove_start": 1.1, "remove_end": 1.5, "reason": "filler: 'um'"}
            ]
        },
        {
            "clip_id": "clip_002", "type": "silent",
            "start_time": 4.2, "end_time": 6.2,
            "keep_original_audio": False,
            "speaker_name": None,
            "narration_text": "[Alex walks to the whiteboard]",
            "koubo_cuts": []
        },
        {
            "clip_id": "clip_003", "type": "dialogue",
            "start_time": 9.8, "end_time": 14.5,
            "keep_original_audio": True,
            "speaker_name": "Sarah",
            "narration_text": None,
            "koubo_cuts": []
        }
    ]

    print(preview_mcp_calls(
        clips=test_clips,
        video_path="/movies/DarkKnight.mp4",
        original_width=2560,
        original_height=1080,
        srt_path="subtitles.srt",
        narration_dir="/tmp/narrations"
    ))

    print("\nKoubo cut test:")
    for sub in _apply_koubo_cuts(test_clips[0]):
        print(f"  sub-segment: {sub['start']:.2f}s → {sub['end']:.2f}s")


path: scripts/jianying_draft.py
content: #!/usr/bin/env python3
"""
jianying_draft.py
Generates JianYing Pro (剪映专业版) importable draft files:
  - draft_meta_info.json
  - draft_content.json

Spec:
  - draft_version : "10.8.0"
  - Resolution    : 1920x1080, fps 30
  - Time unit     : milliseconds (13-digit)
  - Media paths   : Windows D:/... format
  - Subtitles     : JianYing XML <font color="#FFFFFF"><size=60>text</size></font>
  - All IDs       : uuid4, unique, non-repeating
  - JSON          : strict, no trailing commas
"""

import json
import os
import time
import uuid
from pathlib import Path


# ── Helpers ────────────────────────────────────────────────────────────────────
def new_id() -> str:
    """Generate a unique ID (no hyphens, uppercase — JianYing style)."""
    return str(uuid.uuid4()).replace("-", "").upper()


def ms(seconds: float) -> int:
    """Convert seconds to integer milliseconds."""
    return int(round(seconds * 1000))


def to_win_path(path: str) -> str:
    """
    Convert any path to Windows D:/... format for JianYing file_Path fields.
    If already Windows-style, normalise separators.
    """
    p = str(Path(path).resolve())
    # Convert Linux/macOS absolute paths to Windows-style for the draft
    if p.startswith("/"):
        # Best effort: map /home/... or /mnt/... to D:/...
        p = "D:/" + p.lstrip("/").replace("/", "\\")
    else:
        p = p.replace("/", "\\")
    return p


def subtitle_xml(text: str) -> str:
    """Wrap narration text in JianYing subtitle XML format."""
    return f'<font color="#FFFFFF"><size=60>{text}</size></font>'


def unix_now() -> int:
    return int(time.time())


# ── draft_meta_info.json ───────────────────────────────────────────────────────
def build_meta(project_name: str, output_dir: str,
               total_duration_ms: int, draft_uuid: str) -> dict:
    now = unix_now()
    return {
        "cloud_package_attributes": "",
        "create_time": now,
        "draft_cloud_last_action_download": False,
        "draft_cloud_purchase_info": "",
        "draft_cloud_template_id": "",
        "draft_cloud_tutorial_info": "",
        "draft_cloud_videocut_purchase_info": "",
        "draft_cover": "",
        "draft_deeplink_url": "",
        "draft_enterprise_info": {
            "draft_enterprise_extra": "",
            "draft_enterprise_id": "",
            "draft_enterprise_name": ""
        },
        "draft_fold_path": "",
        "draft_id": draft_uuid,
        "draft_is_ai_packaging_used": False,
        "draft_is_ai_shorts_used": False,
        "draft_is_ai_translate_used": False,
        "draft_is_article_video_used": False,
        "draft_is_from_deeplink": False,
        "draft_is_invisible": False,
        "draft_materials": [],
        "draft_name": project_name,
        "draft_need_rename": False,
        "draft_new_version": "",
        "draft_removable_storage_device": "",
        "draft_root_path": to_win_path(output_dir),
        "draft_timeline_materials_size_": 0,
        "draft_type": "",
        "tm_draft_create": now,
        "tm_draft_modified": now,
        "tm_duration": total_duration_ms
    }


# ── Material builders ──────────────────────────────────────────────────────────
def video_material(clip: dict, output_dir: str) -> dict:
    mp4_path = os.path.join(output_dir, f"{clip['clip_id']}.mp4")
    duration = ms(clip["mp3_duration"])
    return {
        "audio_fade": None,
        "cartoon_path": "",
        "category_id": "",
        "category_name": "",
        "check_flag": 63487,
        "crop": {
            "lower_left_x": 0.0, "lower_left_y": 1.0,
            "lower_right_x": 1.0, "lower_right_y": 1.0,
            "upper_left_x": 0.0, "upper_left_y": 0.0,
            "upper_right_x": 1.0, "upper_right_y": 0.0
        },
        "crop_ratio": "free",
        "crop_scale": 1.0,
        "duration": duration,
        "extra_type_option": 0,
        "file_Path": to_win_path(mp4_path),
        "formula_id": "",
        "id": clip["_mat_video_id"],
        "import_time": unix_now(),
        "import_time_ms": unix_now() * 1000,
        "item_source": 1,
        "md5": "",
        "metetype": "video",
        "roughcut_time_range": {"duration": -1, "start": -1},
        "sub_time_range": {"duration": -1, "start": -1},
        "type": "video",
        "video_algorithm": {
            "algorithms": [], "deflicker": None, "motion_blur_config": None,
            "noise_reduction": None, "path": "", "quality_enhance": None,
            "time_range": None
        },
        "width": 1920,
        "height": 1080
    }


def audio_material(clip: dict, output_dir: str) -> dict:
    mp3_path = os.path.join(output_dir, f"narration_{clip['clip_id']}.mp3")
    duration = ms(clip["mp3_duration"])
    return {
        "app_id": 0,
        "category_id": "",
        "category_name": "local",
        "check_flag": 63487,
        "duration": duration,
        "effect_id": "",
        "file_Path": to_win_path(mp3_path),
        "formula_id": "",
        "id": clip["_mat_audio_id"],
        "import_time": unix_now(),
        "import_time_ms": unix_now() * 1000,
        "item_source": 1,
        "md5": "",
        "metetype": "music",
        "name": f"narration_{clip['clip_id']}",
        "roughcut_time_range": {"duration": -1, "start": -1},
        "sub_time_range": {"duration": -1, "start": -1},
        "type": "extract_music"
    }


def text_material(clip: dict) -> dict:
    xml = subtitle_xml(clip.get("narration_text") or clip.get("transcript", ""))
    return {
        "add_type": 0,
        "alignment": 1,
        "background_alpha": 0.0,
        "background_color": "",
        "background_height": 0.08,
        "background_horizontal_offset": 0.0,
        "background_round_radius": 0.0,
        "background_style": 0,
        "background_vertical_offset": 0.0,
        "background_width": 0.14,
        "bold_width": 0.0,
        "border_alpha": 1.0,
        "border_color": "#000000",
        "border_width": 0.08,
        "content": xml,
        "fixed_height": -1.0,
        "fixed_width": -1.0,
        "font_category_id": "",
        "font_category_name": "",
        "font_id": "",
        "font_name": "",
        "font_path": "",
        "font_resource_id": "",
        "font_size": 60.0,
        "font_source_platform": 0,
        "font_time_range": {"duration": 0, "start": 0},
        "font_title": "",
        "font_url": "",
        "fonts": [],
        "force_apply_line_max_width": False,
        "global_alpha": 1.0,
        "group_id": "",
        "has_shadow": True,
        "id": clip["_mat_text_id"],
        "initial_scale": 1.0,
        "is_rich_text": False,
        "italic": False,
        "italic_degree": 0,
        "letter_spacing": 0.0,
        "line_feed": 1,
        "line_max_width": 0.82,
        "line_spacing": 0.02,
        "multi_language_current": "none",
        "name": "",
        "original_size": [],
        "preset_id": "",
        "recognize_task_id": "",
        "recognize_type": 0,
        "relevance_segment": [],
        "shadow_alpha": 0.9,
        "shadow_angle": -45.0,
        "shadow_color": "#000000",
        "shadow_distance": 8.0,
        "shadow_point": {"x": 0.6364, "y": -0.6364},
        "shadow_smoothing": 0.45,
        "shape_clip_x": False,
        "shape_clip_y": False,
        "style_name": "",
        "sub_type": 0,
        "text_alpha": 1.0,
        "text_color": "#FFFFFF",
        "text_curve": None,
        "text_preset_resource_id": "",
        "text_size": 60,
        "text_to_audio_ids": [],
        "tts_auto_update": False,
        "type": "text",
        "typesetting": 0,
        "underline": False,
        "underline_offset": 0.22,
        "underline_width": 0.05,
        "use_effect_default_color": True,
        "words": {"end_time": [], "start_time": [], "text": []}
    }


# ── Segment builders ──────────────────────────────────────────────────────────
def video_segment(clip: dict, timeline_start_ms: int) -> dict:
    dur   = ms(clip["mp3_duration"])
    src_s = ms(clip["start_time"])
    vol   = 1.0 if clip.get("keep_original_audio") else 0.0
    return {
        "cartoon": False,
        "clip": {
            "alpha": 1.0,
            "flip": {"horizontal": False, "vertical": False},
            "rotation": 0.0,
            "scale": {"x": 1.0, "y": 1.0},
            "transform": {"x": 0.0, "y": 0.0}
        },
        "common_keyframes": [],
        "enable_adjust": True,
        "enable_color_correct_adjust": False,
        "enable_color_wheels": False,
        "enable_lut": False,
        "enable_smart_color_adjust": False,
        "extra_material_refs": [],
        "group_id": "",
        "hdr_settings": None,
        "id": new_id(),
        "intensifies_audio": False,
        "is_placeholder": False,
        "is_tone_modify": False,
        "keyframe_refs": [],
        "last_nonzero_volume": 1.0,
        "material_id": clip["_mat_video_id"],
        "render_index": 0,
        "responsive_layout": {
            "enable": False, "horizontal_pos_layout": 0,
            "size_layout": 0, "target_follow": "",
            "vertical_pos_layout": 0
        },
        "reverse": False,
        "source_timerange": {"duration": dur, "start": src_s},
        "speed": 1.0,
        "target_timerange": {"duration": dur, "start": timeline_start_ms},
        "template_id": "",
        "template_scene": "default",
        "track_attribute": 0,
        "track_render_index": 0,
        "uniform_scale": {"on": True, "value": 1.0},
        "visible": True,
        "volume": vol
    }


def audio_segment(clip: dict, timeline_start_ms: int) -> dict:
    dur = ms(clip["mp3_duration"])
    return {
        "app_id": 0,
        "category_id": "",
        "category_name": "local",
        "check_flag": 63487,
        "duration": dur,
        "effect_id": "",
        "id": new_id(),
        "intensifies_path": "",
        "is_ai_clone_tone": False,
        "is_text_edit_overdub": False,
        "is_unified_beauty_mode": False,
        "local_material_id": clip["_mat_audio_id"],
        "material_id": clip["_mat_audio_id"],
        "material_name": f"narration_{clip['clip_id']}",
        "render_index": 0,
        "source_timerange": {"duration": dur, "start": 0},
        "speed": 1.0,
        "target_timerange": {"duration": dur, "start": timeline_start_ms},
        "type": "extract_music",
        "volume": 1.0
    }


def text_segment(clip: dict, timeline_start_ms: int) -> dict:
    dur = ms(clip["mp3_duration"])
    xml = subtitle_xml(clip.get("narration_text") or clip.get("transcript", ""))
    return {
        "content": xml,
        "id": new_id(),
        "is_style_definition_segment": False,
        "keyframe_refs": [],
        "material_id": clip["_mat_text_id"],
        "render_index": 0,
        "target_timerange": {"duration": dur, "start": timeline_start_ms},
        "track_attribute": 0,
        "track_render_index": 0,
        "visible": True,
        "z_index": 0
    }


# ── Main generator ─────────────────────────────────────────────────────────────
def generate_jianying_draft(
    project_name: str,
    clips: list,
    output_dir: str,
    original_width: int = 1920,
    original_height: int = 1080
) -> tuple:
    """
    Generate draft_meta_info.json and draft_content.json for JianYing Pro.

    Each clip must have:
      clip_id, start_time, mp3_duration, narration_text (or transcript),
      keep_original_audio (bool)

    Returns: (meta_dict, content_dict)
    """
    output_dir = str(Path(output_dir).resolve())
    draft_uuid = new_id()

    # Pre-assign material IDs to each clip (stable, used in both materials + segments)
    for clip in clips:
        clip["_mat_video_id"] = new_id()
        clip["_mat_audio_id"] = new_id()
        clip["_mat_text_id"]  = new_id()

    # Build materials
    video_mats = [video_material(c, output_dir) for c in clips]
    audio_mats = [audio_material(c, output_dir) for c in clips
                  if not c.get("keep_original_audio")]
    text_mats  = [text_material(c) for c in clips
                  if c.get("narration_text") or c.get("transcript")]

    # Build segments (walk clips in order, accumulate timeline cursor)
    cursor_ms = 0
    video_segs, audio_segs, text_segs = [], [], []

    for clip in clips:
        dur = ms(clip["mp3_duration"])

        video_segs.append(video_segment(clip, cursor_ms))

        if not clip.get("keep_original_audio"):
            audio_segs.append(audio_segment(clip, cursor_ms))

        txt = clip.get("narration_text") or clip.get("transcript", "")
        if txt:
            text_segs.append(text_segment(clip, cursor_ms))

        cursor_ms += dur

    total_ms = cursor_ms

    # Assemble draft_content.json
    content = {
        "canvas_config": {
            "height": original_height,
            "ratio": "original",
            "width": original_width
        },
        "color_space": 0,
        "config": {
            "adjust_max_index": 1,
            "attachment_info": [],
            "combination_max_index": 1,
            "export_range": None,
            "extract_audio_last_index": 1,
            "lyrics_recognition_id": "",
            "lyrics_sync": True,
            "lyrics_taskinfo": [],
            "maintrack_adsorb": True,
            "material_save_mode": 0,
            "multi_language_current": "none",
            "multi_language_list": [],
            "multi_language_main": "none",
            "multi_language_mode": "none",
            "original_sound_last_index": 1,
            "record_audio_last_index": 1,
            "sticker_max_index": 1,
            "subtitle_recognition_id": "",
            "subtitle_sync": True,
            "subtitle_taskinfo": [],
            "system_font_list": [],
            "video_mute": False,
            "zoom_info_params": None
        },
        "cover": "",
        "create_time": 0,
        "duration": total_ms,
        "extra_info": None,
        "fps": 30.0,
        "free_render_index_mode_on": False,
        "group_container": None,
        "id": draft_uuid,
        "keyframe_graph_list": [],
        "keyframes": {
            "adjusts": [], "audios": [], "effects": [], "filters": [],
            "handwrites": [], "stickers": [], "texts": [], "videos": []
        },
        "lyrics_effects_size_": 0,
        "materials": {
            "audios": audio_mats,
            "videos": video_mats,
            "texts":  text_mats
        },
        "mutable_config": None,
        "name": project_name,
        "new_version": "10.8.0",
        "platform": {
            "app_id": 3704,
            "app_source": "lv",
            "app_version": "5.9.0",
            "device_id": "",
            "hard_disk_id": "",
            "mac_id": "",
            "os": "windows",
            "os_version": ""
        },
        "relationships": [],
        "render_index_track_mode_on": False,
        "retouch_cover": None,
        "source": "default",
        "static_cover_image_path": "",
        "time_marks": None,
        "tracks": [
            {
                "attribute": 0,
                "flag": 0,
                "id": new_id(),
                "is_default_name": True,
                "name": "",
                "segments": video_segs,
                "type": "video"
            },
            {
                "attribute": 0,
                "flag": 0,
                "id": new_id(),
                "is_default_name": True,
                "name": "",
                "segments": audio_segs,
                "type": "audio"
            },
            {
                "attribute": 0,
                "flag": 0,
                "id": new_id(),
                "is_default_name": True,
                "name": "",
                "segments": text_segs,
                "type": "text"
            }
        ],
        "update_time": 0,
        "version": 360000
    }

    meta = build_meta(project_name, output_dir, total_ms, draft_uuid)
    return meta, content


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    test_clips = [
        {
            "clip_id": "clip_001",
            "start_time": 200.0,
            "mp3_duration": 6.34,
            "narration_text": "眼前这个陆永瑜，只是举报了父亲的贪腐行为，才发现自己已经踏入了一个无法回头的深渊...",
            "keep_original_audio": False
        },
        {
            "clip_id": "clip_002",
            "start_time": 1125.0,
            "mp3_duration": 5.81,
            "narration_text": "随后，陆金强拿出了那段视频——弑父的证据，一字一句，把他逼入了死角。",
            "keep_original_audio": False
        },
        {
            "clip_id": "clip_003",
            "start_time": 4350.0,
            "mp3_duration": 6.10,
            "narration_text": "就在此时，律师张远当庭播放未剪辑原版监控，陆金强的谎言彻底崩塌。",
            "keep_original_audio": False
        }
    ]

    meta, content = generate_jianying_draft(
        project_name    = "误判",
        clips           = test_clips,
        output_dir      = "/tmp/jianying_test",
        original_width  = 1920,
        original_height = 1080
    )

    import json, os
    os.makedirs("/tmp/jianying_test", exist_ok=True)
    with open("/tmp/jianying_test/draft_meta_info.json", "w", encoding="utf-8") as f:
        json.dump(meta, f, ensure_ascii=False, indent=2)
    with open("/tmp/jianying_test/draft_content.json", "w", encoding="utf-8") as f:
        json.dump(content, f, ensure_ascii=False, indent=2)

    print(f"draft_meta_info.json  {os.path.getsize('/tmp/jianying_test/draft_meta_info.json')} bytes")
    print(f"draft_content.json    {os.path.getsize('/tmp/jianying_test/draft_content.json')} bytes")
    print(f"Total duration        {content['duration']}ms  ({content['duration']/1000:.1f}s)")
    print(f"Video tracks          {len(content['tracks'][0]['segments'])} segments")
    print(f"Audio tracks          {len(content['tracks'][1]['segments'])} segments")
    print(f"Text tracks           {len(content['tracks'][2]['segments'])} segments")
    print()

    # Validate: check all IDs are unique
    all_ids = []
    for mat_type in content["materials"].values():
        all_ids += [m["id"] for m in mat_type]
    for track in content["tracks"]:
        all_ids += [track["id"]]
        all_ids += [s["id"] for s in track["segments"]]
    all_ids.append(content["id"])

    dupes = [x for x in all_ids if all_ids.count(x) > 1]
    if dupes:
        print(f"DUPLICATE IDs FOUND: {dupes}")
    else:
        print(f"ID uniqueness check: PASS ({len(all_ids)} unique IDs)")

    # Validate: check subtitle XML format
    for seg in content["tracks"][2]["segments"]:
        assert seg["content"].startswith("<font color="), f"Bad XML: {seg['content']}"
    print("Subtitle XML check: PASS")


path: scripts/narration_audio.py
content: #!/usr/bin/env python3
"""
narration_audio.py

Handles the 4 strict outputs:
  1. narration_clip_XXX.mp3  — one per clip, edge-tts
  2. clip_XXX.mp4            — one per clip, ffmpeg (duration = mp3_duration)
  3. subtitles.srt           — timeline built from cumulative mp3 durations
  4. draft_content.json      — assembled by build_payload.clips_to_draft()

Key rule: mp3_duration is the single source of truth for all timing.
"""

import asyncio
import json
import os
import subprocess
import sys
from pathlib import Path

VOICE_DEFAULT = "zh-CN-XiaoxiaoNeural"


# ── 1. TTS: narration_clip_XXX.mp3 ────────────────────────────────────────────

async def _tts_async(text: str, path: str, voice: str):
    try:
        import edge_tts
    except ImportError:
        subprocess.run([sys.executable, "-m", "pip", "install", "edge-tts",
                        "--break-system-packages", "-q"], check=True)
        import edge_tts
    clean = text.strip().strip("[]").strip()
    if not clean:
        clean = "画面过渡"
    tts = edge_tts.Communicate(clean, voice=voice)
    await tts.save(path)


def get_audio_duration(path: str) -> float:
    """Measure ACTUAL mp3 duration via ffprobe."""
    r = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=noprint_wrappers=1:nokey=1", path],
        capture_output=True, text=True, check=True
    )
    return round(float(r.stdout.strip()), 3)


def generate_narration_mp3s(clips: list, output_dir: str,
                             voice: str = VOICE_DEFAULT) -> list:
    """
    Generate one MP3 per clip that has narration_text.
    Updates each clip in-place with:
      clip["mp3_path"]     = "video_clipper_output/narration_clip_001.mp3"
      clip["mp3_duration"] = 6.342   ← ACTUAL measured duration

    Returns updated clips list.
    """
    Path(output_dir).mkdir(parents=True, exist_ok=True)

    for clip in clips:
        text = clip.get("narration_text") or clip.get("transcript", "")
        if not text:
            clip["mp3_path"] = None
            clip["mp3_duration"] = clip["end_time"] - clip["start_time"]
            continue

        mp3_path = str(Path(output_dir) / f"narration_{clip['clip_id']}.mp3")
        print(f"  TTS {clip['clip_id']}: {text[:50]}...")
        asyncio.run(_tts_async(text, mp3_path, voice))
        duration = get_audio_duration(mp3_path)

        clip["mp3_path"]     = mp3_path
        clip["mp3_duration"] = duration
        # Lock end_time to match mp3 duration
        clip["end_time"]     = round(clip["start_time"] + duration, 3)

        print(f"    → {mp3_path}  ({duration:.3f}s)")

    return clips


# ── 2. Video clips: clip_XXX.mp4 ──────────────────────────────────────────────

def render_clip_mp4(clip: dict, video_path: str, output_dir: str,
                    original_width: int, original_height: int) -> str:
    """
    Extract one clip from source video. Duration = clip["mp3_duration"].
    - Merges narration MP3 if available (mutes source audio)
    - Keeps source audio if keep_original_audio=True
    - Silent if neither
    Preserves original aspect ratio.
    """
    out_path = str(Path(output_dir) / f"{clip['clip_id']}.mp4")
    duration = clip["mp3_duration"]
    vf = (f"scale={original_width}:{original_height}"
          f":force_original_aspect_ratio=decrease:flags=lanczos")

    base = ["ffmpeg", "-y",
            "-ss", str(clip["start_time"]),
            "-t",  str(duration),
            "-i",  video_path]

    mp3 = clip.get("mp3_path")

    if mp3 and Path(mp3).exists() and not clip.get("keep_original_audio"):
        cmd = base + [
            "-i", mp3,
            "-map", "0:v:0", "-map", "1:a:0",
            "-c:v", "libx264", "-preset", "fast", "-vf", vf,
            "-c:a", "aac", "-b:a", "192k", "-shortest", out_path
        ]
    elif clip.get("keep_original_audio"):
        cmd = base + [
            "-c:v", "libx264", "-preset", "fast", "-vf", vf,
            "-c:a", "aac", "-b:a", "192k", out_path
        ]
    else:
        cmd = base + [
            "-c:v", "libx264", "-preset", "fast", "-vf", vf,
            "-an", out_path
        ]

    result = subprocess.run(cmd, capture_output=True)
    if result.returncode != 0:
        raise RuntimeError(
            f"ffmpeg failed for {clip['clip_id']}: {result.stderr.decode()[:200]}"
        )

    clip["mp4_path"] = out_path
    print(f"  {clip['clip_id']}.mp4  {duration:.3f}s")
    return out_path


def render_all_clips(clips: list, video_path: str, output_dir: str,
                     original_width: int, original_height: int) -> list:
    """Render all clip_XXX.mp4 files. Returns updated clips."""
    Path(output_dir).mkdir(parents=True, exist_ok=True)
    failed = []
    for clip in clips:
        try:
            render_clip_mp4(clip, video_path, output_dir,
                            original_width, original_height)
        except Exception as e:
            print(f"  WARNING: {clip['clip_id']} render failed — {e}")
            clip["mp4_path"] = None
            failed.append(clip["clip_id"])
    if failed:
        print(f"  Failed clips: {failed}")
    return clips


# ── 3. Subtitles: subtitles.srt ───────────────────────────────────────────────

def _fmt_srt_time(seconds: float) -> str:
    """Convert seconds to SRT timestamp string HH:MM:SS,mmm"""
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    ms = int(round((seconds % 1) * 1000))
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"


def generate_srt(clips: list, output_path: str):
    """
    Build SRT where each entry's timing is the cumulative sum of mp3_durations.
    This matches the final concatenated playback timeline exactly.

    clip_001  mp3_duration=6.340  → 00:00:00,000 → 00:00:06,340
    clip_002  mp3_duration=5.810  → 00:00:06,340 → 00:00:12,150
    clip_003  mp3_duration=7.200  → 00:00:12,150 → 00:00:19,350
    ...
    """
    cursor = 0.0
    lines = []

    for i, clip in enumerate(clips, 1):
        duration = clip.get("mp3_duration", clip["end_time"] - clip["start_time"])
        start_ts = cursor
        end_ts   = cursor + duration

        text = clip.get("narration_text") or clip.get("transcript", "")
        speaker = clip.get("speaker_name")
        label = f"[{speaker}] {text}" if speaker else text

        lines.append(
            f"{i}\n"
            f"{_fmt_srt_time(start_ts)} --> {_fmt_srt_time(end_ts)}\n"
            f"{label}\n"
        )

        # Write back SRT timestamps to clip for draft_content.json
        clip["srt_start"] = _fmt_srt_time(start_ts)
        clip["srt_end"]   = _fmt_srt_time(end_ts)

        cursor = end_ts

    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))

    print(f"SRT saved: {output_path}  ({len(lines)} entries, total {cursor:.3f}s)")
    return output_path


# ── 4. draft_content.json (assembled here, schema defined) ────────────────────

def build_draft_json(clips: list, video_path: str,
                     original_width: int, original_height: int,
                     project_name: str = "Movie Clip") -> dict:
    """
    Build the JianYing/CapCut importable draft JSON.
    Each clip entry references its mp4_path, mp3_path, and SRT timestamps.
    """
    draft_clips = []
    for clip in clips:
        draft_clips.append({
            "clip_id":              clip["clip_id"],
            "mp4_path":             clip.get("mp4_path"),
            "mp3_path":             clip.get("mp3_path"),
            "start_time":           clip["start_time"],
            "end_time":             clip["end_time"],
            "duration":             clip.get("mp3_duration",
                                             clip["end_time"] - clip["start_time"]),
            "narration":            clip.get("narration_text") or clip.get("transcript", ""),
            "keep_original_audio":  clip.get("keep_original_audio", False),
            "speaker_name":         clip.get("speaker_name"),
            "srt_start":            clip.get("srt_start"),
            "srt_end":              clip.get("srt_end"),
        })

    total_duration = sum(c["duration"] for c in draft_clips)

    return {
        "project_name":   project_name,
        "source_video":   str(Path(video_path).resolve()),
        "aspect_ratio":   {
            "width":  original_width,
            "height": original_height,
            "mode":   "preserve"
        },
        "total_duration": round(total_duration, 3),
        "clip_count":     len(draft_clips),
        "clips":          draft_clips
    }


# ── Master pipeline function ───────────────────────────────────────────────────

def run_output_pipeline(clips: list, video_path: str, output_dir: str,
                         original_width: int, original_height: int,
                         project_name: str = "Movie Clip",
                         voice: str = VOICE_DEFAULT) -> dict:
    """
    Runs steps 4-7 in sequence:
      Step 4: Generate narration_clip_XXX.mp3 + measure actual duration
      Step 5: Render clip_XXX.mp4 (duration locked to mp3)
      Step 6: Generate subtitles.srt (timing from mp3 durations)
      Step 7: Build + save draft_content.json

    Returns paths dict with all 4 output locations.
    """
    Path(output_dir).mkdir(parents=True, exist_ok=True)

    print("\n[Step 4] Generating narration MP3s...")
    clips = generate_narration_mp3s(clips, output_dir, voice)

    print("\n[Step 5] Rendering clip MP4s...")
    clips = render_all_clips(clips, video_path, output_dir,
                              original_width, original_height)

    print("\n[Step 6] Generating subtitles.srt...")
    srt_path = generate_srt(clips, f"{output_dir}/subtitles.srt")

    print("\n[Step 7] Building draft_content.json...")
    draft = build_draft_json(clips, video_path, original_width,
                              original_height, project_name)
    draft_path = f"{output_dir}/draft_content.json"
    with open(draft_path, "w", encoding="utf-8") as f:
        json.dump(draft, f, ensure_ascii=False, indent=2)
    print(f"Draft saved: {draft_path}")

    # Summary
    mp3s = [c.get("mp3_path") for c in clips if c.get("mp3_path")]
    mp4s = [c.get("mp4_path") for c in clips if c.get("mp4_path")]

    print(f"""
✅ 生成完成！

📁 输出目录: {output_dir}/

  配音文件 (narration_*.mp3): {len(mp3s)} 个
  视频切片 (clip_*.mp4):      {len(mp4s)} 个
  字幕文件: subtitles.srt     ({len(clips)} 条)
  剪映草稿: draft_content.json
""")

    return {
        "mp3_files":   mp3s,
        "mp4_files":   mp4s,
        "srt_path":    srt_path,
        "draft_path":  draft_path,
        "clips":       clips,
    }


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    # Dry-run: test SRT generation with mock clips
    test_clips = [
        {
            "clip_id": "clip_001", "start_time": 0.0, "end_time": 6.0,
            "narration_text": "眼前这个陆永瑜，只是举报了父亲的贪腐行为，才发现自己已经踏入深渊...",
            "mp3_duration": 6.34, "speaker_name": None
        },
        {
            "clip_id": "clip_002", "start_time": 4500.0, "end_time": 4506.0,
            "narration_text": "就在此时，陆金强拿出了那段视频，把他逼入死角。",
            "mp3_duration": 5.81, "speaker_name": None
        },
        {
            "clip_id": "clip_003", "start_time": 6600.0, "end_time": 6607.0,
            "narration_text": "最终，法庭上陆金强的谎言彻底崩塌，真相大白。",
            "mp3_duration": 7.20, "speaker_name": None
        },
    ]

    # Test SRT
    import tempfile
    with tempfile.NamedTemporaryFile(suffix=".srt", delete=False, mode="w") as f:
        srt_tmp = f.name
    generate_srt(test_clips, srt_tmp)
    print("\nSRT content:")
    print(open(srt_tmp).read())

    # Test draft JSON
    draft = build_draft_json(test_clips, "/movies/test.mp4", 1920, 1080, "测试项目")
    print("Draft JSON (summary):")
    print(f"  project: {draft['project_name']}")
    print(f"  clips: {draft['clip_count']}")
    print(f"  total_duration: {draft['total_duration']}s")
    for c in draft["clips"]:
        print(f"  {c['clip_id']}  {c['srt_start']} → {c['srt_end']}  {c['duration']:.3f}s")


path: scripts/narration_writer.py
content: #!/usr/bin/env python3
"""
narration_writer.py
Uses Claude API (via Anthropic) to write cinematic, emotionally gripping
narration scripts for movie clips.

Narration style rules:
- Opening hook: "眼前这个[角色]只是[做了什么]，才[意外结果]..."
- Use suspense, rhythm, contrast to pull viewers in
- Each segment ≤ 40 Chinese characters
- Transition words: 随后、然而、就在此时、与此同时、没想到、令人意外的是、最终
- Voice: dramatic, storytelling, third-person omniscient
"""

import os
import json
import re

# ── Global API config ──────────────────────────────────────────────────────────
ANTHROPIC_API_KEY = None   # fallback: os.environ.get("ANTHROPIC_API_KEY")
NARRATION_MODEL   = "claude-sonnet-4-20250514"

# Narration style templates for different story stages
STAGE_HOOKS = {
    "opening": [
        "眼前这个{character}，只是{action}，没想到竟然{twist}……",
        "谁也没料到，{character}的一个{action}，彻底改变了……",
        "故事开始得很平静——{character}只是{action}，然而……",
    ],
    "development": [
        "随后，事情开始失控——",
        "然而，就在这时，",
        "没想到，{character}却{action}，",
    ],
    "climax": [
        "就在此时，一切走向了无法挽回的边缘——",
        "令人窒息的是，",
        "与此同时，{character}做出了一个改变命运的决定——",
    ],
    "resolution": [
        "最终，真相浮出水面——",
        "一切尘埃落定，",
        "谁也没想到，结局会是这样——",
    ]
}


def write_narration_script(
    events: list,
    movie_name: str,
    characters: list = None,
    language: str = "zh",
    style: str = "dramatic"
) -> list:
    """
    Call Claude to write a full cinematic narration script for the movie.

    Args:
        events     : list of plot events from plot_researcher.parse_plot_to_events()
        movie_name : movie title
        characters : list of {"name": "...", "role": "..."} dicts
        language   : "zh" (Chinese) or "en" (English)
        style      : "dramatic" | "suspense" | "emotional"

    Returns:
        list of narration dicts with 'narration_text' field added
    """
    [REDACTED_SECRET] or os.environ.get("ANTHROPIC_API_KEY")
    if not [REDACTED_SECRET]"No ANTHROPIC_API_KEY found. Using rule-based narration fallback.")
        return _fallback_narration(events, movie_name)

    try:
        import anthropic
    except ImportError:
        import subprocess, sys
        subprocess.run([sys.executable, "-m", "pip", "install", "anthropic",
                        "--break-system-packages", "-q"], check=True)
        import anthropic

    client = anthropic.Anthropic([REDACTED_SECRET])

    char_desc = ""
    if characters:
        char_desc = "主要角色：\n" + "\n".join(
            f"- {c['name']}（{c.get('role', '未知')}）" for c in characters
        )

    events_text = "\n".join(
        f"[{e['event_id']}] {e['stage']} @ {e['video_timestamp']}\n  情节：{e['description']}"
        for e in events
    )

    style_guide = {
        "dramatic": "极具感染力、戏剧张力强、节奏紧凑",
        "suspense": "悬念感强、欲言又止、引发好奇",
        "emotional": "情感深沉、共鸣强烈、直击人心"
    }.get(style, "极具感染力")

    prompt = f"""你是一位专业的电影解说文案写手，风格{style_guide}。

电影名称：《{movie_name}》
{char_desc}

以下是电影的关键情节节点，请为每个节点写一段旁白文案：

{events_text}

要求：
1. 第一段必须用悬念开头，格式如：
   "眼前这个[角色名]，只是[做了一件普通的事]，才[出乎意料的结果]……"
   或"谁也没料到，[角色]的一个[动作]，竟然……"

2. 每段文案不超过40个汉字，语言简洁有力

3. 段与段之间用过渡词衔接：随后、然而、就在此时、与此同时、没想到、令人意外的是、最终

4. 禁止剧透结局（结局段除外），用省略号制造悬念

5. 语气：第三人称全知视角，像讲故事一样娓娓道来但充满紧迫感

请严格按以下JSON格式返回，不要输出任何其他内容：
{{
  "narrations": [
    {{
      "event_id": "evt_001",
      "narration_text": "旁白文案内容"
    }}
  ]
}}"""

    response = client.messages.create(
        model=NARRATION_MODEL,
        max_tokens=2000,
        messages=[{"role": "user", "content": prompt}]
    )

    raw = response.content[0].text.strip()

    # Parse JSON response
    try:
        # Strip markdown fences if present
        clean = re.sub(r"```json|```", "", raw).strip()
        result = json.loads(clean)
        narration_map = {n["event_id"]: n["narration_text"] for n in result["narrations"]}
    except Exception as e:
        print(f"JSON parse failed ({e}), using fallback.")
        return _fallback_narration(events, movie_name)

    # Merge narration text back into events
    output = []
    for event in events:
        narration_text = narration_map.get(event["event_id"], "")
        if not narration_text:
            # Fallback for any missing event
            narration_text = _single_fallback(event)
        output.append({**event, "narration_text": narration_text})

    return output


def refine_narration(
    narrations: list,
    feedback: str,
    movie_name: str
) -> list:
    """
    Re-call Claude to refine narrations based on user feedback.
    e.g. "第二段太平淡了，加强悬念感" or "开头改成更震撼的风格"
    """
    [REDACTED_SECRET] or os.environ.get("ANTHROPIC_API_KEY")
    if not [REDACTED_SECRET]"No API key. Cannot refine.")
        return narrations

    try:
        import anthropic
    except ImportError:
        import subprocess, sys
        subprocess.run([sys.executable, "-m", "pip", "install", "anthropic",
                        "--break-system-packages", "-q"], check=True)
        import anthropic

    client = anthropic.Anthropic([REDACTED_SECRET])

    current = "\n".join(
        f"[{n['event_id']}] {n['narration_text']}" for n in narrations
    )

    prompt = f"""以下是《{movie_name}》的旁白文案：

{current}

用户反馈：{feedback}

请根据反馈修改文案，保持其他段落不变，只修改需要改动的部分。
每段仍不超过40个汉字。

严格按JSON返回：
{{
  "narrations": [
    {{"event_id": "evt_001", "narration_text": "修改后的文案"}}
  ]
}}"""

    response = client.messages.create(
        model=NARRATION_MODEL,
        max_tokens=2000,
        messages=[{"role": "user", "content": prompt}]
    )

    raw = response.content[0].text.strip()
    try:
        clean = re.sub(r"```json|```", "", raw).strip()
        result = json.loads(clean)
        narration_map = {n["event_id"]: n["narration_text"] for n in result["narrations"]}
        return [{**n, "narration_text": narration_map.get(n["event_id"], n["narration_text"])}
                for n in narrations]
    except Exception as e:
        print(f"Refinement parse failed ({e}). Returning original.")
        return narrations


# ── Rule-based fallback ────────────────────────────────────────────────────────
def _fallback_narration(events: list, movie_name: str) -> list:
    """Simple rule-based narration when no API key is available."""
    result = []
    for i, event in enumerate(events):
        result.append({**event, "narration_text": _single_fallback(event, i)})
    return result


def _single_fallback(event: dict, index: int = 0) -> str:
    stage = event.get("stage", "development")
    desc  = event.get("description", "")

    if index == 0 or stage == "opening":
        return f"故事就这样开始了——{desc[:30]}……"
    elif stage == "climax":
        return f"就在此时，{desc[:30]}……"
    elif stage == "resolution":
        return f"最终，{desc[:30]}。"
    else:
        return f"随后，{desc[:30]}……"


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    test_events = [
        {"event_id": "evt_001", "stage": "opening",
         "video_timestamp": "00:05:00", "video_seconds": 300,
         "description": "陆永瑜意外发现父亲陆建业贪污受贿的证据"},
        {"event_id": "evt_002", "stage": "development",
         "video_timestamp": "00:25:00", "video_seconds": 1500,
         "description": "陆金强带人找到废车场监听室，拿视频要挟陆永瑜"},
        {"event_id": "evt_003", "stage": "climax",
         "video_timestamp": "01:10:00", "video_seconds": 4200,
         "description": "陆永瑜被迫签下认罪书，律师张远发现监控录像有剪辑痕迹"},
        {"event_id": "evt_004", "stage": "resolution",
         "video_timestamp": "01:45:00", "video_seconds": 6300,
         "description": "法庭上伪证被当庭揭穿，陆永瑜无罪释放"},
    ]
    characters = [
        {"name": "陆永瑜", "role": "主角"},
        {"name": "陆金强", "role": "反派"},
        {"name": "张远",   "role": "律师"},
    ]

    results = write_narration_script(test_events, "误判", characters)
    print("\n=== Generated Narrations ===")
    for n in results:
        print(f"\n[{n['event_id']}] {n['stage']} @ {n['video_timestamp']}")
        print(f"  {n['narration_text']}")


path: scripts/plot_researcher.py
content: #!/usr/bin/env python3
"""
plot_researcher.py
Step 0: Search movie plot online via multi-search-engine tool,
        parse into timeline events, and rewrite as narration script.

Called by Claude as part of the pipeline — not a standalone CLI.
All functions return structured data for downstream use.
"""

import re
import json

# ── Narration transition words ─────────────────────────────────────────────────
TRANSITIONS = {
    "opening":     ["故事开始于", "一切的起点是", "影片伊始，"],
    "development": ["随后，", "紧接着，", "与此同时，", "就在这时，"],
    "climax":      ["然而，", "就在此刻，", "危机悄然降临——", "命运的转折点出现了，"],
    "resolution":  ["最终，", "就此，", "故事的结局是，", "尘埃落定，"],
    "neutral":     ["随后，", "然而，", "与此同时，", "不久后，", "就在此时，"]
}

# ── Search query builder ───────────────────────────────────────────────────────
def build_search_queries(movie_name: str) -> list:
    """
    Return a list of search queries to send to multi-search-engine.
    Covers: plot summary, scene timestamps, key events.
    """
    return [
        f"{movie_name} 剧情详细介绍 起因发展高潮结局",
        f"{movie_name} 电影剧情时间线 精彩片段时间点",
        f"{movie_name} plot summary timeline key scenes",
        f"{movie_name} 电影解说 分段剧情",
    ]


# ── Plot parser ────────────────────────────────────────────────────────────────
def parse_plot_to_events(raw_plot: str, movie_duration_minutes: float = None) -> list:
    """
    Parse raw plot text (from search results) into structured timeline events.

    Returns list of:
    {
        "event_id": "evt_001",
        "stage": "opening|development|climax|resolution",
        "video_timestamp": "01:15:00",   # HH:MM:SS or MM:SS from search result
        "video_seconds": 4500,           # converted to seconds
        "description": "陆金强带人找到废车场监听室，拿陆永瑜弑父视频要挟",
        "raw_text": "..."
    }
    """
    events = []
    counter = 1

    # Try to extract timestamp-prefixed lines (e.g. "01:15:00 陆金强...")
    timestamp_pattern = re.compile(
        r'(\d{1,3}:\d{2}(?::\d{2})?)\s*[：:]\s*(.+)'
    )

    lines = raw_plot.strip().split('\n')
    for line in lines:
        line = line.strip()
        if not line:
            continue

        m = timestamp_pattern.match(line)
        if m:
            ts_str = m.group(1)
            desc = m.group(2).strip()
            seconds = _ts_to_seconds(ts_str)
            stage = _infer_stage(seconds, movie_duration_minutes)
            events.append({
                "event_id": f"evt_{counter:03d}",
                "stage": stage,
                "video_timestamp": ts_str,
                "video_seconds": seconds,
                "description": desc,
                "raw_text": line
            })
            counter += 1

    # If no timestamps found, split into 4 logical stages from prose
    if not events:
        events = _split_prose_to_stages(raw_plot, counter)

    return events


def _ts_to_seconds(ts: str) -> int:
    """Convert HH:MM:SS or MM:SS to total seconds."""
    parts = ts.strip().split(':')
    parts = [int(p) for p in parts]
    if len(parts) == 3:
        return parts[0] * 3600 + parts[1] * 60 + parts[2]
    elif len(parts) == 2:
        return parts[0] * 60 + parts[1]
    return 0


def _infer_stage(seconds: int, total_seconds: float = None) -> str:
    """Infer narrative stage from timestamp position."""
    if not total_seconds:
        total_seconds = 7200  # assume 2h default
    ratio = seconds / total_seconds
    if ratio < 0.15:
        return "opening"
    elif ratio < 0.5:
        return "development"
    elif ratio < 0.85:
        return "climax"
    else:
        return "resolution"


def _split_prose_to_stages(text: str, start_counter: int) -> list:
    """Fallback: split prose plot into 4 equal stage segments."""
    sentences = re.split(r'[。！？.!?]', text)
    sentences = [s.strip() for s in sentences if len(s.strip()) > 10]

    n = len(sentences)
    stages = ["opening", "development", "climax", "resolution"]
    chunk = max(1, n // 4)
    events = []
    counter = start_counter

    for i, stage in enumerate(stages):
        chunk_sents = sentences[i * chunk: (i + 1) * chunk]
        if not chunk_sents:
            continue
        events.append({
            "event_id": f"evt_{counter:03d}",
            "stage": stage,
            "video_timestamp": None,
            "video_seconds": None,
            "description": "。".join(chunk_sents),
            "raw_text": "。".join(chunk_sents)
        })
        counter += 1

    return events


# ── Narration script writer ────────────────────────────────────────────────────
def events_to_narration(events: list, movie_name: str = "") -> list:
    """
    Rewrite each event description into a narration sentence with
    appropriate transition words.

    Returns list of:
    {
        "narration_id": "nar_001",
        "event_id": "evt_001",
        "stage": "opening",
        "video_timestamp": "01:15:00",
        "video_seconds": 4500,
        "narration_text": "故事开始于一场意外——陆金强带人找到废车场...",
        "estimated_tts_seconds": None  # filled in by tts_generator.py
    }
    """
    import random
    narrations = []
    stage_counts = {}

    for i, evt in enumerate(events):
        stage = evt.get("stage", "neutral")
        count = stage_counts.get(stage, 0)
        stage_counts[stage] = count + 1

        # Pick transition word
        pool = TRANSITIONS.get(stage, TRANSITIONS["neutral"])
        # Use first transition for stage's first event, rotate after
        transition = pool[count % len(pool)]

        desc = evt["description"].strip()
        # Remove leading transitions already in desc
        for t in ["随后", "然而", "就在", "最终", "紧接着", "与此同时"]:
            if desc.startswith(t):
                desc = desc[len(t):].lstrip("，,、")
                break

        # Compose narration sentence
        narration = f"{transition}{desc}"
        if not narration.endswith(("。", "！", "？", "…", "——")):
            narration += "。"

        narrations.append({
            "narration_id": f"nar_{i+1:03d}",
            "event_id": evt["event_id"],
            "stage": stage,
            "video_timestamp": evt.get("video_timestamp"),
            "video_seconds": evt.get("video_seconds"),
            "narration_text": narration,
            "estimated_tts_seconds": None  # set after TTS generation
        })

    return narrations


# ── Duration estimator (before TTS) ───────────────────────────────────────────
def estimate_tts_duration(text: str, chars_per_second: float = 5.5) -> float:
    """
    Rough estimate of TTS audio duration before actually generating it.
    Chinese: ~5.5 chars/sec at normal speed.
    """
    clean = re.sub(r'[^\u4e00-\u9fff\w]', '', text)
    return round(len(clean) / chars_per_second, 2)


def add_duration_estimates(narrations: list) -> list:
    """Add estimated_tts_seconds to each narration item."""
    for n in narrations:
        n["estimated_tts_seconds"] = estimate_tts_duration(n["narration_text"])
    return narrations


# ── Total duration check (target 3–5 min) ─────────────────────────────────────
def check_total_duration(narrations: list) -> dict:
    """
    Check if total estimated TTS duration is within 3–5 minutes.
    Returns status and recommendation.
    """
    total = sum(n.get("estimated_tts_seconds", 0) for n in narrations)
    target_min, target_max = 180, 300  # 3–5 minutes

    return {
        "total_seconds": round(total, 1),
        "total_display": f"{int(total//60)}:{int(total%60):02d}",
        "within_target": target_min <= total <= target_max,
        "recommendation": (
            "✅ 时长合适" if target_min <= total <= target_max
            else f"⚠️ 偏{'短' if total < target_min else '长'}，建议{'增加' if total < target_min else '精简'}段落"
        )
    }


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    # Simulate parsed plot with timestamps
    sample_plot = """
01:00:00：陆永瑜发现父亲陆建业贪污受贿的证据，愤而举报
01:15:00：陆金强带人找到废车场监听室，拿陆永瑜弑父视频要挟
01:35:00：陆永瑜被迫签下认罪书，案件陷入僵局
01:50:00：律师张远发现监控录像存在剪辑痕迹，真相浮出水面
02:05:00：法庭上陆金强的伪证被当庭揭穿，陆永瑜无罪释放
"""
    events = parse_plot_to_events(sample_plot, movie_duration_minutes=120)
    narrations = events_to_narration(events, movie_name="误判")
    narrations = add_duration_estimates(narrations)
    status = check_total_duration(narrations)

    print(f"共 {len(narrations)} 段旁白  |  预估总时长: {status['total_display']}  |  {status['recommendation']}\n")
    for n in narrations:
        print(f"[{n['narration_id']}] {n['video_timestamp']}  ~{n['estimated_tts_seconds']}s")
        print(f"  {n['narration_text']}\n")

    print("Search queries:")
    for q in build_search_queries("误判"):
        print(f"  - {q}")


path: scripts/run_clip_pipeline.py
content: #!/usr/bin/env python3
"""
Full video clip pipeline.
Usage: python run_clip_pipeline.py --video ./movie.mp4 --vectcut-key YOUR_KEY
"""

import argparse
import json
import os
import sys
import time
from pathlib import Path


# ── Dependency check ──────────────────────────────────────────────────────────
def check_dependencies(whisper_mode: str):
    missing = []
    try:
        import anthropic
    except ImportError:
        missing.append("anthropic")
    try:
        import requests
    except ImportError:
        missing.append("requests")
    if whisper_mode == "local":
        try:
            import whisper
        except ImportError:
            missing.append("openai-whisper")
    else:
        try:
            from openai import OpenAI
        except ImportError:
            missing.append("openai")

    if missing:
        print(f"Missing dependencies. Install with: pip install {' '.join(missing)} --break-system-packages")
        sys.exit(1)


# ── Step 1: Whisper transcription ─────────────────────────────────────────────
def transcribe_local(video_path: str, model_size: str = "medium", language: str = None) -> list:
    """Transcribe using a local Whisper model."""
    import whisper

    print(f"Loading Whisper model: {model_size}")
    model = whisper.load_model(model_size)

    print(f"Transcribing: {video_path}")
    options = {"verbose": False, "word_timestamps": False}
    if language:
        options["language"] = language

    result = model.transcribe(video_path, **options)

    segments = [
        {"start": s["start"], "end": s["end"], "text": s["text"].strip()}
        for s in result["segments"]
        if s["text"].strip()
    ]
    print(f"Transcription complete — {len(segments)} segments found.")
    return segments


def transcribe_api(video_path: str, api_key: str, language: str = None) -> list:
    """Transcribe using the OpenAI Whisper API."""
    from openai import OpenAI

    client = OpenAI([REDACTED_SECRET])
    print(f"Calling OpenAI Whisper API: {video_path}")

    kwargs = {"model": "whisper-1", "response_format": "verbose_json",
              "timestamp_granularities": ["segment"]}
    if language:
        kwargs["language"] = language

    with open(video_path, "rb") as f:
        transcript = client.audio.transcriptions.create(file=f, **kwargs)

    segments = [
        {"start": s.start, "end": s.end, "text": s.text.strip()}
        for s in transcript.segments
        if s.text.strip()
    ]
    print(f"Transcription complete — {len(segments)} segments found.")
    return segments


# ── Step 2: Claude highlight identification ───────────────────────────────────
def identify_highlights(segments: list, highlight_count: int = 5,
                         clip_duration: int = 60, content_type: str = "general") -> list:
    """Use Claude to identify the best highlight clips."""
    import anthropic

    client = anthropic.Anthropic()

    transcript_text = "\n".join([
        f"[{seg['start']:.1f}s - {seg['end']:.1f}s] {seg['text']}"
        for seg in segments
    ])

    total_duration = segments[-1]["end"] if segments else 0

    prompt = f"""You are a professional short-video editor. Your job is to identify the best clips from a longer video for standalone social media sharing.

Video duration: {total_duration:.0f}s ({total_duration/60:.1f} min)
Content type: {content_type}

Full transcript with timestamps:
{transcript_text}

Identify the {highlight_count} best clips for standalone sharing. Each clip should be approximately {clip_duration} seconds.

Selection criteria:
1. Emotional impact — tension, conflict, turning points, humor
2. Information density — key insights, quotable moments
3. Narrative completeness — a clear beginning and end, not abrupt
4. Shareability — the first line hooks the viewer immediately
5. Variety — clips should cover different content

Return ONLY valid JSON in this exact format, no markdown or extra text:
{{"highlights": [{{"start": 0.0, "end": 60.0, "title": "Clip title", "reason": "Why this clip was selected", "hook": "Opening hook line", "energy_level": "high"}}]}}

energy_level options: high / medium / low"""

    print(f"Claude is analyzing content to find {highlight_count} highlights...")

    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=3000,
        messages=[{"role": "user", "content": prompt}]
    )

    raw = message.content[0].text.strip().replace("```json", "").replace("```", "").strip()
    result = json.loads(raw)
    highlights = result["highlights"]

    print(f"Found {len(highlights)} highlights:")
    for i, h in enumerate(highlights, 1):
        print(f"  {i}. [{h['start']:.0f}s–{h['end']:.0f}s] {h['title']} — {h['reason']}")

    return highlights


# ── Step 3: vectcut-api draft generation ──────────────────────────────────────
def generate_draft(video_source: str, highlights: list, api_key: str,
                   style: str = "viral", ratio: str = "9:16") -> str:
    """Call vectcut-api to generate a CapCut draft. Returns draft_id."""
    import requests

    clips = [
        {
            "index": i,
            "source_start": round(h["start"], 3),
            "source_end": round(h["end"], 3),
            "title": h.get("title", f"Clip {i+1}"),
            "caption": h.get("hook", ""),
            "energy_level": h.get("energy_level", "medium"),
        }
        for i, h in enumerate(highlights)
    ]

    payload = {
        "video_source": video_source,
        "clips": clips,
        "template": {
            "style": style,
            "ratio": ratio,
            "resolution": "1080p",
            "caption_style": "subtitle_center",
            "transition": "fade",
            "bgm": "auto",
            "color_grade": "cinematic",
        },
        "export": {
            "format": "jianying_draft",
            "include_captions": True,
            "auto_color_grade": True,
            "generate_cover": True,
        }
    }

    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}

    print(f"Submitting vectcut-api job (style: {style}, ratio: {ratio})...")
    resp = requests.post("https://api.vectcut.com/v1/drafts/generate",
                         json=payload, headers=headers, timeout=30)
    resp.raise_for_status()

    draft_id = resp.json()["draft_id"]
    print(f"Job submitted. draft_id: {draft_id}")
    return draft_id


def poll_and_download(draft_id: str, api_key: str, output_path: str) -> str:
    """Poll for completion and download the draft."""
    import requests

    headers = {"Authorization": f"Bearer {api_key}"}
    print("Waiting for generation to complete...")

    for _ in range(72):  # up to 6 minutes
        resp = requests.get(f"https://api.vectcut.com/v1/drafts/{draft_id}/status",
                            headers=headers, timeout=10)
        status = resp.json()
        state = status.get("state", "unknown")
        progress = status.get("progress", 0)

        if state == "completed":
            print(f"\nGeneration complete!")
            dl_resp = requests.get(status["download_url"], stream=True, timeout=120)
            Path(output_path).parent.mkdir(parents=True, exist_ok=True)
            with open(output_path, "wb") as f:
                for chunk in dl_resp.iter_content(8192):
                    f.write(chunk)
            print(f"Draft saved: {output_path}")
            return output_path
        elif state == "failed":
            raise RuntimeError(f"Generation failed: {status.get('error', 'unknown error')}")
        else:
            print(f"\r  Progress: {progress}%  ", end="", flush=True)
            time.sleep(5)

    raise TimeoutError(f"Timed out. Query draft_id manually: {draft_id}")


# ── Timeline HTML preview ──────────────────────────────────────────────────────
def generate_timeline_html(highlights: list, total_duration: float, output_path: str):
    """Generate a visual timeline HTML preview."""
    colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7",
              "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9"]

    bars = ""
    for i, h in enumerate(highlights):
        left = (h["start"] / total_duration) * 100
        width = ((h["end"] - h["start"]) / total_duration) * 100
        color = colors[i % len(colors)]
        title = h.get("title", f"Clip {i+1}")
        start_fmt = f"{int(h['start']//60)}:{int(h['start']%60):02d}"
        end_fmt = f"{int(h['end']//60)}:{int(h['end']%60):02d}"
        bars += (f'<div class="clip" style="left:{left:.1f}%;width:{width:.1f}%;'
                 f'background:{color};" title="{title}: {start_fmt} - {end_fmt}">'
                 f'<span>{i+1}</span></div>')

    clips_list = "".join([
        f'<div class="clip-item">'
        f'<span class="num" style="background:{colors[i%len(colors)]}">{i+1}</span>'
        f'<div><strong>{h.get("title","")}</strong>'
        f'<small>{int(h["start"]//60)}:{int(h["start"]%60):02d} – '
        f'{int(h["end"]//60)}:{int(h["end"]%60):02d} '
        f'({int(h["end"]-h["start"])}s)</small>'
        f'<p>{h.get("hook","")}</p></div></div>'
        for i, h in enumerate(highlights)
    ])

    total_min = int(total_duration // 60)
    total_sec = int(total_duration % 60)

    html = f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>Clip Timeline Preview</title>
<style>
  body {{ font-family: -apple-system, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; background: #0f0f0f; color: #eee; }}
  h1 {{ color: #fff; font-size: 1.4em; }}
  .timeline-wrap {{ background: #1a1a1a; border-radius: 12px; padding: 20px; margin: 20px 0; }}
  .timeline {{ position: relative; height: 60px; background: #2a2a2a; border-radius: 8px; overflow: hidden; }}
  .clip {{ position: absolute; top: 8px; height: 44px; border-radius: 6px; opacity: 0.9; display: flex; align-items: center; justify-content: center; cursor: pointer; }}
  .clip:hover {{ opacity: 1; box-shadow: 0 0 0 2px white; }}
  .clip span {{ color: #000; font-weight: 700; font-size: 14px; }}
  .time-labels {{ display: flex; justify-content: space-between; color: #666; font-size: 12px; margin-top: 6px; }}
  .clips-list {{ display: flex; flex-direction: column; gap: 12px; margin-top: 20px; }}
  .clip-item {{ display: flex; gap: 12px; align-items: flex-start; background: #1a1a1a; padding: 14px; border-radius: 10px; }}
  .num {{ min-width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; color: #000; font-size: 13px; }}
  .clip-item strong {{ display: block; margin-bottom: 2px; }}
  .clip-item small {{ color: #888; font-size: 12px; display: block; margin-bottom: 4px; }}
  .clip-item p {{ margin: 0; font-size: 13px; color: #aaa; font-style: italic; }}
</style></head>
<body>
<h1>Video Clip Timeline Preview</h1>
<p style="color:#888">Total: {total_min}m {total_sec}s | {len(highlights)} clips</p>
<div class="timeline-wrap">
  <div class="timeline">{bars}</div>
  <div class="time-labels">
    <span>0:00</span>
    <span>{int(total_duration//4//60)}:{int(total_duration//4%60):02d}</span>
    <span>{int(total_duration//2//60)}:{int(total_duration//2%60):02d}</span>
    <span>{int(total_duration*3//4//60)}:{int(total_duration*3//4%60):02d}</span>
    <span>{total_min}:{total_sec:02d}</span>
  </div>
</div>
<div class="clips-list">{clips_list}</div>
</body></html>"""

    with open(output_path, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"Timeline preview saved: {output_path}")


# ── Main entry point ──────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="Video clip pipeline")
    parser.add_argument("--video", required=True, help="Input video file path")
    parser.add_argument("--output", default="./output", help="Output directory")
    parser.add_argument("--highlights", type=int, default=5, help="Number of clips to extract")
    parser.add_argument("--duration", type=int, default=60, help="Target duration per clip (seconds)")
    parser.add_argument("--style", default="viral",
                        choices=["viral", "cinematic", "vlog", "interview"],
                        help="CapCut template style")
    parser.add_argument("--ratio", default="9:16", choices=["9:16", "16:9", "1:1"],
                        help="Output aspect ratio")
    parser.add_argument("--language", default=None, help="Transcription language code (e.g. en, zh, ja)")
    parser.add_argument("--content-type", default="general",
                        help="Content type hint (e.g. interview, documentary, standup)")
    parser.add_argument("--whisper-mode", default="local", choices=["local", "api"],
                        help="Whisper mode: local model or OpenAI API")
    parser.add_argument("--whisper-model", default="medium",
                        choices=["tiny", "base", "small", "medium", "large-v3"],
                        help="Local Whisper model size")
    parser.add_argument("--openai-key", default=os.environ.get("OPENAI_API_KEY"),
                        help="OpenAI API key (required for api mode)")
    parser.add_argument("--vectcut-key", default=os.environ.get("VECTCUT_API_KEY"),
                        help="vectcut API key")
    parser.add_argument("--skip-vectcut", action="store_true",
                        help="Skip vectcut — output highlights.json only")
    args = parser.parse_args()

    check_dependencies(args.whisper_mode)

    output_dir = Path(args.output)
    output_dir.mkdir(parents=True, exist_ok=True)

    print(f"\n{'='*50}")
    print(f"Video Clip Pipeline")
    print(f"{'='*50}")
    print(f"Input : {args.video}")
    print(f"Clips : {args.highlights} x ~{args.duration}s | Style: {args.style}")
    print(f"{'='*50}\n")

    # Step 1: Transcription (skip if cached)
    transcript_path = output_dir / "transcript.json"
    if transcript_path.exists():
        print(f"Found cached transcript, skipping transcription: {transcript_path}")
        with open(transcript_path) as f:
            segments = json.load(f)
    else:
        if args.whisper_mode == "local":
            segments = transcribe_local(args.video, args.whisper_model, args.language)
        else:
            if not args.openai_key:
                print("Error: --openai-key or OPENAI_API_KEY required for api mode.")
                sys.exit(1)
            segments = transcribe_api(args.video, args.openai_key, args.language)

        with open(transcript_path, "w", encoding="utf-8") as f:
            json.dump(segments, f, ensure_ascii=False, indent=2)
        print(f"Transcript saved: {transcript_path}")

    if not segments:
        print("Error: transcript is empty. Check the video file.")
        sys.exit(1)

    # Step 2: Identify highlights
    highlights = identify_highlights(
        segments,
        highlight_count=args.highlights,
        clip_duration=args.duration,
        content_type=args.content_type
    )

    highlights_path = output_dir / "highlights.json"
    with open(highlights_path, "w", encoding="utf-8") as f:
        json.dump(highlights, f, ensure_ascii=False, indent=2)
    print(f"Highlights saved: {highlights_path}")

    total_duration = segments[-1]["end"] if segments else 0
    generate_timeline_html(highlights, total_duration, str(output_dir / "timeline_preview.html"))

    # Step 3: vectcut-api
    if args.skip_vectcut:
        print("\nSkipping vectcut. Pipeline complete.")
    elif not args.vectcut_key:
        print("\nNo --vectcut-key provided. Skipping draft generation.")
        print("highlights.json is ready — you can call vectcut-api manually.")
    else:
        draft_id = generate_draft(
            video_source=str(Path(args.video).absolute()),
            highlights=highlights,
            [REDACTED_SECRET],
            style=args.style,
            ratio=args.ratio
        )
        draft_path = str(output_dir / "jianying_draft.zip")
        poll_and_download(draft_id, args.vectcut_key, draft_path)
        print(f"\nUnzip {draft_path} and use 'Import Draft' in CapCut to open it.")

    print(f"\n{'='*50}")
    print(f"Done! Output directory: {output_dir}")
    print(f"{'='*50}\n")


if __name__ == "__main__":
    main()


path: scripts/tts_generator.py
content: #!/usr/bin/env python3
"""
tts_generator.py
Step 2: Generate per-segment narration audio via edge-tts.
        Measures actual audio duration after generation.
        Outputs to: {output_dir}/audio/nar_001.mp3 ...
"""

import asyncio
import json
import os
import subprocess
from pathlib import Path


# ── Global config ──────────────────────────────────────────────────────────────
TTS_VOICE   = "zh-CN-XiaoxiaoNeural"   # natural female Mandarin voice
TTS_RATE    = "+0%"                     # normal speed; use "+10%" to speed up
AUDIO_SUBDIR = "audio"                  # subfolder under output_dir


# ── Single segment TTS ─────────────────────────────────────────────────────────
async def _tts_single(text: str, output_path: str,
                       voice: str = TTS_VOICE, rate: str = TTS_RATE):
    """Generate one MP3 file via edge-tts."""
    try:
        import edge_tts
    except ImportError:
        subprocess.run(
            ["pip", "install", "edge-tts", "--break-system-packages", "-q"],
            check=True
        )
        import edge_tts

    communicate = edge_tts.Communicate(text, voice=voice, rate=rate)
    await communicate.save(output_path)


def _get_audio_duration(mp3_path: str) -> float:
    """Use ffprobe to get exact audio duration in seconds."""
    cmd = [
        "ffprobe", "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        mp3_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    try:
        return round(float(result.stdout.strip()), 3)
    except Exception:
        return 0.0


# ── Batch TTS generation ───────────────────────────────────────────────────────
def generate_all_narrations(narrations: list, output_dir: str,
                             voice: str = TTS_VOICE) -> list:
    """
    Generate MP3 for every narration segment.
    Updates each item with:
      - audio_path: absolute path to MP3
      - actual_tts_seconds: real duration from ffprobe

    Returns updated narrations list.

    Output structure:
      {output_dir}/
      └── audio/
          ├── nar_001.mp3
          ├── nar_002.mp3
          └── ...
    """
    audio_dir = Path(output_dir) / AUDIO_SUBDIR
    audio_dir.mkdir(parents=True, exist_ok=True)

    updated = []
    for n in narrations:
        nid = n["narration_id"]
        text = n["narration_text"]
        mp3_path = str(audio_dir / f"{nid}.mp3")

        print(f"[TTS] {nid}: '{text[:40]}...' → {mp3_path}")
        try:
            asyncio.run(_tts_single(text, mp3_path, voice=voice))
            duration = _get_audio_duration(mp3_path)
            print(f"       ✅ {duration}s")
        except Exception as e:
            print(f"       ❌ TTS failed: {e}")
            duration = n.get("estimated_tts_seconds", 5.0)
            mp3_path = None

        updated.append({
            **n,
            "audio_path": mp3_path,
            "actual_tts_seconds": duration
        })

    return updated


# ── Clip-to-narration alignment ────────────────────────────────────────────────
def align_clips_to_narrations(narrations: list, video_path: str) -> list:
    """
    For each narration segment, compute the video clip window:
      - clip_start = video_seconds (from search result timestamp)
      - clip_end   = video_seconds + actual_tts_seconds
      (i.e. take the X seconds of video starting at the timestamp,
       where X = narration audio duration)

    If video_seconds is None, auto-distribute evenly.
    Returns list of clip dicts compatible with build_payload.clips_to_draft().
    """
    clips = []

    # Get video duration for auto-distribution fallback
    video_duration = _get_video_duration(video_path)

    # Find segments without timestamps for auto-distribution
    timed = [n for n in narrations if n.get("video_seconds") is not None]
    untimed = [n for n in narrations if n.get("video_seconds") is None]

    # Auto-assign timestamps for untimed segments
    if untimed and video_duration:
        timed_seconds = [n["video_seconds"] for n in timed]
        gaps = _find_gaps(timed_seconds, video_duration)
        for i, n in enumerate(untimed):
            if i < len(gaps):
                n["video_seconds"] = gaps[i]

    for i, n in enumerate(narrations):
        duration = n.get("actual_tts_seconds") or n.get("estimated_tts_seconds") or 5.0
        vs = n.get("video_seconds") or 0

        clip_start = round(vs, 3)
        clip_end   = round(vs + duration, 3)

        clips.append({
            "clip_id":            f"clip_{i+1:03d}",
            "narration_id":       n["narration_id"],
            "stage":              n.get("stage", "neutral"),
            "start_time":         clip_start,
            "end_time":           clip_end,
            "duration":           round(duration, 3),
            "type":               "narration",
            "keep_original_audio": False,      # replace with narration audio
            "narration_text":     n["narration_text"],
            "audio_path":         n.get("audio_path"),
            "subtitles": [{
                "start": clip_start,
                "end":   clip_end,
                "text":  n["narration_text"]
            }]
        })

    return clips


def _get_video_duration(video_path: str) -> float:
    """Get total video duration in seconds via ffprobe."""
    cmd = [
        "ffprobe", "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        video_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    try:
        return float(result.stdout.strip())
    except Exception:
        return 0.0


def _find_gaps(timed_seconds: list, video_duration: float) -> list:
    """Find good insertion points for untimed segments."""
    timed_sorted = sorted(timed_seconds)
    gaps = []
    prev = 0
    for ts in timed_sorted:
        mid = (prev + ts) / 2
        gaps.append(round(mid, 1))
        prev = ts
    # After last timed event
    gaps.append(round((prev + video_duration) / 2, 1))
    return gaps


# ── SRT writer ─────────────────────────────────────────────────────────────────
def clips_to_srt(clips: list, output_path: str) -> str:
    """Write SRT subtitle file from aligned clips."""
    def fmt(s: float) -> str:
        h, rem = divmod(s, 3600)
        m, sec = divmod(rem, 60)
        ms = int((sec % 1) * 1000)
        return f"{int(h):02d}:{int(m):02d}:{int(sec):02d},{ms:03d}"

    # Build SRT with timeline-offset subtitles (start from 0 in output video)
    cursor = 0.0
    lines = []
    for i, clip in enumerate(clips, 1):
        dur = clip["duration"]
        lines += [
            str(i),
            f"{fmt(cursor)} --> {fmt(cursor + dur)}",
            clip["narration_text"],
            ""
        ]
        cursor += dur

    content = "\n".join(lines)
    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write(content)
    print(f"[SRT] Saved: {output_path}")
    return output_path


# ── CLI test ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    sample = [
        {
            "narration_id": "nar_001",
            "event_id": "evt_001",
            "stage": "opening",
            "video_timestamp": "01:00:00",
            "video_seconds": 3600,
            "narration_text": "故事开始于一场意外——陆永瑜发现父亲贪污受贿的证据，愤而举报。",
            "estimated_tts_seconds": 6.5
        },
        {
            "narration_id": "nar_002",
            "event_id": "evt_002",
            "stage": "development",
            "video_timestamp": "01:15:00",
            "video_seconds": 4500,
            "narration_text": "随后，陆金强带人找到废车场监听室，拿陆永瑜弑父视频要挟。",
            "estimated_tts_seconds": 6.0
        }
    ]

    # Duration check only (skip actual TTS in test)
    for n in sample:
        dur = n["estimated_tts_seconds"]
        vs = n["video_seconds"]
        print(f"[{n['narration_id']}] video {n['video_timestamp']} → clip {vs}s–{vs+dur:.1f}s  ({dur}s)")
        print(f"  旁白: {n['narration_text']}\n")


path: scripts/whisper_transcribe.py
content: #!/usr/bin/env python3
"""
Local Whisper transcription script.
Usage: python whisper_transcribe.py <audio_path> [--model medium] [--language en] [--output segments.json]
"""
import argparse
import json
import sys
import time
from pathlib import Path


def transcribe(audio_path: str, model_name: str = "medium", language: str = None) -> list:
    """
    Transcribe audio using Whisper. Returns a list of segments.
    """
    try:
        import whisper
    except ImportError:
        print("openai-whisper not found. Installing...")
        import subprocess
        subprocess.run([sys.executable, "-m", "pip", "install", "openai-whisper",
                        "--break-system-packages", "-q"], check=True)
        import whisper

    print(f"Loading Whisper model: {model_name}")
    model = whisper.load_model(model_name)

    options = {}
    if language:
        options["language"] = language

    print(f"Transcribing: {audio_path}")
    start_time = time.time()

    result = model.transcribe(audio_path, **options, verbose=False)

    elapsed = time.time() - start_time
    print(f"Done. Took {elapsed:.1f}s — {len(result['segments'])} segments found.")

    segments = []
    for seg in result["segments"]:
        segments.append({
            "id": seg["id"],
            "start": round(seg["start"], 3),
            "end": round(seg["end"], 3),
            "text": seg["text"].strip()
        })

    return segments


def main():
    parser = argparse.ArgumentParser(description="Whisper audio transcription tool")
    parser.add_argument("audio_path", help="Path to audio file (wav/mp3/m4a)")
    parser.add_argument("--model", default="medium",
                        choices=["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"],
                        help="Whisper model size (default: medium)")
    parser.add_argument("--language", default=None, help="Force language, e.g. en, zh, ja")
    parser.add_argument("--output", default="segments.json", help="Output JSON path")
    args = parser.parse_args()

    audio_path = Path(args.audio_path)
    if not audio_path.exists():
        print(f"Error: audio file not found: {audio_path}")
        sys.exit(1)

    segments = transcribe(str(audio_path), model_name=args.model, language=args.language)

    output_path = Path(args.output)
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(segments, f, ensure_ascii=False, indent=2)

    print(f"Saved to: {output_path}")

    if segments:
        total_duration = segments[-1]["end"]
        print(f"\nSummary:")
        print(f"  Duration : {total_duration:.1f}s ({total_duration/60:.1f} min)")
        print(f"  Segments : {len(segments)}")
        print(f"\nFirst 3 segments:")
        for seg in segments[:3]:
            print(f"  [{seg['start']:.1f}s – {seg['end']:.1f}s] {seg['text']}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)