๐ŸŽ Get the FREE AI Skills Starter Guide โ€” Subscribe โ†’
BytesAgainBytesAgain
๐Ÿฆ€ ClawHub

clip-editor

by @riclinccc

Video clip editing skill for automatically analyzing video content and generating CapCut draft templates. Uses local Whisper for speech transcription, Qwen-V...

Versionv1.0.0
Downloads514
TERMINAL
clawhub install video-clip-editor

๐Ÿ“– About This Skill


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๏ผ‰"

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.

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

# Get width, height, duration
ffprobe -v error -select_streams v:0 \
  -show_entries stream=width,height,duration \
  -of json 

Store: original_width, original_height, video_duration_seconds.

Narration Word Budget (ๅญ—ๆ•ฐ้ข„็ฎ—)

Core rule: 1 minute of output video = 200 Chinese characters of narration

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:

    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".

    [
      {
        "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 (ๅ…ฑ็”จ)

    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, ...

    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, ...

    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).

    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: ๆ–‡ๅญ— | | All IDs | Unique, non-repeating (use uuid4()) | | JSON | Strict โ€” no trailing commas, no comments |


    draft_meta_info.json

    {
      "cloud_package_attributes": "",
      "create_time": 1700000000,
      "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": "{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

    {
      "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:

    {
      "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):

    {
      "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):

    {
      "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 ๆ ผๅผ):

    ็œผๅ‰่ฟ™ไธช้™†ๆฐธ็‘œ๏ผŒๅชๆ˜ฏไธพๆŠฅไบ†็ˆถไบฒ็š„่ดช่…่กŒไธบ
    


    Material Templates

    Video material:

    {
      "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:

    {
      "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:

    {
      "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

    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

    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

    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