Skill: gilbertwuu/video-aroll-auto-editor (version 1.0.3)

# Video Auto Editor v4.7 A rule-based and AI-powered automated video roughing tool. It automatically identifies the best segments from original footage and performs editing and splicing. > **Best for**: **Single-person / talking-to-camera (A'Roll) content** — vlogs, tutorials, podcasts, knowledge-sharing monologues. Not suitable for multi-person dialogues, interviews, or music/B-roll heavy content. --- ## Features - **Scenario A - Single Video**: Automatically selects the best segment from one video - **Scenario B - Batch Processing**: Processes multiple videos, performs cross-video deduplication, and concatenates into one final video - **Smart Scoring**: 4-dimension scoring (clear start/end, fluency, natural rhythm) + fluency analysis - **Content Deduplication**: Similarity detection based on transcription text, both within-video and cross-video - **Auto Reports**: Generates detailed Markdown reports for each processing run --- ## Best For / Not Suitable | ✅ Best For | ❌ Not Suitable | |-------------|-----------------| | Single-person talking to camera (A'Roll) | Multi-person dialogues, interviews | | Vlogs, tutorials, podcasts, monologues | Music-heavy, B-roll heavy content | | Multiple takes of same content (batch dedup) | Content requiring multiple segments kept | | Chinese speech (fluency patterns tuned) | Non-Chinese (patterns not adapted) | | Raw long footage (rough cut) | Already tightly edited content | --- ## Requirements - **Python** 3.8+ - **FFmpeg** (including ffprobe) - **openai-whisper** ### Installation ```bash # macOS brew install ffmpeg pip install openai-whisper # Ubuntu / Debian sudo apt install ffmpeg pip install openai-whisper ``` --- ## Quick Start ### Scenario A: Single Video Pass a **video file path**: ```bash python3 video_editor_auto_v4.6.py ./video.MTS ./output ``` Output: ``` output/ ├── video_粗剪.mp4 # Best segment (clipped) └── video_报告.md # Processing report ``` ### Scenario B: Batch + Deduplication + Concatenation Pass a **folder path** (automatically detected as batch mode): ```bash python3 video_editor_auto_v4.6.py ./Video ./output ``` Output (only two files, intermediate files are cleaned up): ``` output/ ├── 最终拼接_20260311_1905.mp4 # Deduplicated concatenated video └── 批量处理报告.md # Batch report (segment details + dedup decisions) ``` ### Command Format ``` python3 video_editor_auto_v4.6.py <input> [output_dir] [work_dir] ``` | Parameter | Description | Default | |------------|-------------|---------| | `<input>` | Video file path (Scenario A) or folder path (Scenario B) | Required | | `[output_dir]` | Output directory for clips and reports | `./output` | | `[work_dir]` | Temporary directory for intermediate files | `./video_work` | Supported formats: `.MTS`, `.mp4`, `.mov` --- ## Processing Pipeline ### Scenario A (Single Video) ``` Input video → Silence detection → Segment identification → 4-dimension scoring → Candidate filtering → Whisper transcription → Fluency analysis → Within-video dedup → Layered selection → Clip output ``` ### Scenario B (Batch) ``` Input directory → Process each video (Scenario A, no individual reports) → Cross-video deduplication → Concatenate by filename order → Clean intermediate files → Generate single batch report ``` --- ## Configuration All parameters are in the `CONFIG` dict at the top of the script: ```python CONFIG = { # Silence detection "silence_noise": -30, # dB, lower = stricter "silence_duration": 0.8, # seconds, minimum silence length # Filtering "min_score": 90, # Minimum base score (max 100) "min_duration": 15, # Minimum segment duration (seconds) # Clip buffer "buffer_start": 1, # Buffer before start (seconds) "buffer_end": 3, # Buffer after end (seconds) # Encoding "crf": 18, # Video quality (18=visually lossless, 23=default) "preset": "fast", # Encoding speed "audio_bitrate": "192k", # Audio bitrate # Adjusted score weights "penalty_repeat": 5, # Per repeat penalty "penalty_stutter": 3, # Per stutter penalty "penalty_interrupt": 10, # Sudden interruption penalty "bonus_natural_end": 5, # Natural ending bonus "bonus_completeness_max": 3, # Completeness bonus cap # Deduplication "duplicate_threshold": 0.7, # Content similarity threshold (0-1) } ``` ### Tuning Tips | Scenario | Parameter | Suggested Value | |----------|-----------|-----------------| | Noisy environment | `silence_noise` | `-35` | | Segments too fragmented | `silence_duration` | `1.0` | | Want more candidates | `min_score` | `85` | | Want shorter segments | `min_duration` | `10` | | Higher quality | `crf` | `15` (larger files) | --- ## Scoring System ### Base Score (4 dimensions × 25 points = 100) | Dimension | Max | Criteria | |-----------|-----|----------| | Clear start | 25 | Sufficient silence before segment | | Clear end | 25 | Sufficient silence after segment | | Mid fluency | 25 | Fewer internal interruptions | | Natural rhythm | 25 | Low pause ratio + no overly long pauses + not too short | ### Adjusted Score (0-100) Applied on top of base score based on transcription analysis: | Item | Points | Description | |------|--------|-------------| | Repeat penalty | -5 each | "Re-said" type stutters (normalized per 30s) | | Stutter penalty | -3 each | Filler words (um, uh, etc.) | | Interruption penalty | -10 | Ends with connective words (then, but, etc.) | | Natural end bonus | +5 | Complete sentence, question, or summary ending | | Completeness bonus | +0~3 | Natural end + duration near 60s | ### Layered Selection (Choosing Best Segment) Not simply the highest score; prioritized filtering: ``` Layer 1: Prefer naturally ending segments Layer 2: Sort by fluency (tolerance 1.5 per 30s) Layer 3: Sort by adjusted score Layer 4: Tie-break → incomplete: pick last; complete: pick longest ``` ### Deduplication Rules Same selection rule for within-video and cross-video dedup: ``` Natural end > Adjusted score > Index/filename order (later preferred) ``` --- ## FAQ ### Q: No silence segments detected? Background noise may cause misdetection. Try lowering `silence_noise` from `-30` to `-35`. ### Q: Segments cut too finely? Silence duration threshold may be too short. Increase `silence_duration` from `0.8` to `1.0` or `1.5`. ### Q: Why wasn't the highest-scoring segment chosen? The system uses **layered selection**, not raw score comparison. Natural end > Fluency > Adjusted score > Duration. A 95-point segment that ends abruptly may rank lower than a 90-point segment with a natural ending. ### Q: Whisper transcription inaccurate? Default uses `small` model for speed/accuracy balance. To improve: - In `transcribe_segment`, change `--model small` to `--model medium` or `--model large` - `medium` model has ~85% accuracy for Chinese; recommended if resources allow ### Q: How does cross-video dedup decide which to keep? Selection rule: Natural end > Adjusted score > Later filename (usually last take, best state). ### Q: Where are the detailed reports? - Scenario A: `output/<video_name>_报告.md` - Scenario B: `output/批量处理报告.md` (includes segment details, transcription summaries, dedup decisions) - Scenario B does not keep intermediate reports or clips; they are cleaned after concatenation --- ## Project Structure ``` video_editor_v4.6_release/ ├── video_editor_auto_v4.6.py # Main script ├── README.md # This doc ├── CODE_DOCUMENTATION.md # Technical doc (architecture, modules, API) ├── requirements.txt # Python dependencies ├── LICENSE # GPL v3 license └── .gitignore # Git ignore rules ``` --- ## Technical Documentation For module implementation details, data structures, algorithms, and extension guides, see `CODE_DOCUMENTATION.md`. --- ## License This project is licensed under **GPL v3**. If you use this software or its derivatives for commercial purposes, you must release your product as open source under the same license (GPL v3 copyleft requirement). See the [LICENSE](LICENSE) file for details. --- **Version**: v4.7 | **Last Updated**: 2026-03-11

path: video_editor_auto_v4.6.py
content: #!/usr/bin/env python3
"""
Video Auto Editor v4.7
Copyright (C) 2026

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY. See the LICENSE file for details.

Scenario A: Single video (silence detection -> segment identification -> scoring
            -> transcription -> fluency analysis -> dedup -> clip)
Scenario B: Batch processing -> cross-video dedup -> concatenation

Dependencies: FFmpeg, openai-whisper
"""
import subprocess, re, os, sys, json, glob, difflib, datetime, shutil
from dataclasses import dataclass, field
from typing import List, Tuple, Optional

# --- Config ---
CONFIG = {
    "silence_noise": -30,           # dB, lower = stricter
    "silence_duration": 0.8,        # seconds, min silence length
    "min_score": 90,                # min base score (max 100)
    "min_duration": 15,             # min segment duration (seconds)
    "buffer_start": 1,              # buffer before start (seconds)
    "buffer_end": 3,                # buffer after end (seconds)
    "crf": 18,                      # video quality (18=visually lossless)
    "preset": "fast",               # encoding speed
    "audio_bitrate": "192k",        # audio bitrate
    "penalty_repeat": 5,            # penalty per repeat
    "penalty_stutter": 3,            # penalty per stutter
    "penalty_interrupt": 10,        # sudden interruption penalty
    "bonus_natural_end": 5,         # natural ending bonus
    "bonus_completeness_max": 3,    # completeness bonus cap
    "duplicate_threshold": 0.7,     # content similarity threshold
}

# --- Data structures ---
@dataclass
class Segment:
    """Video segment (non-silent interval)"""
    index: int
    start_time: float
    end_time: float
    duration: float
    score_start: float = 0
    score_end: float = 0
    score_fluency: float = 0
    score_rhythm: float = 0
    total_score: float = 0
    internal_silences: List[Tuple[float, float]] = field(default_factory=list)
    interruption_count: int = 0
    interruption_duration: float = 0
    transcript: str = ""
    repeat_count: int = 0
    stutter_count: int = 0
    is_natural_end: bool = False
    is_interrupted: bool = False
    adjusted_score: float = 0
    is_duplicate: bool = False
    duplicate_with: List[int] = field(default_factory=list)

@dataclass
class ClipInfo:
    """Single video rough-cut result, for cross-video dedup"""
    video_name: str
    clip_path: str
    transcript: str
    adjusted_score: float
    is_natural_end: bool
    duration: float
    is_cross_duplicate: bool = False
    duplicate_of: str = ""

# --- Module 1: Silence detection ---
def detect_silence(video_path):
    """Detect silence spans using FFmpeg silencedetect filter"""
    cmd = [
        "ffmpeg", "-i", video_path,
        "-af", f"silencedetect=noise={CONFIG['silence_noise']}dB:d={CONFIG['silence_duration']}",
        "-f", "null", "-"
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    starts = re.findall(r'silence_start: ([\d.]+)', result.stderr)
    ends = re.findall(r'silence_end: ([\d.]+)', result.stderr)
    return [(float(starts[i]), float(ends[i])) for i in range(min(len(starts), len(ends)))]

# --- Module 2: Segment identification ---
def identify_segments(silences, total_duration):
    """Split video into segments by silence (>=1s non-silent intervals)"""
    if not silences:
        return [Segment(index=0, start_time=0, end_time=total_duration, duration=total_duration)]

    segments = []
    idx = 0

    first_end = silences[0][0]
    if first_end > 1.0:
        segments.append(Segment(index=idx, start_time=0, end_time=first_end, duration=first_end))
        idx += 1

    for i in range(len(silences) - 1):
        start, end = silences[i][1], silences[i+1][0]
        duration = end - start
        if duration >= 1.0:
            segments.append(Segment(index=idx, start_time=start, end_time=end, duration=duration))
            idx += 1

    last_start = silences[-1][1]
    if total_duration - last_start > 1.0:
        segments.append(Segment(index=idx, start_time=last_start, end_time=total_duration,
                                duration=total_duration - last_start))

    return segments

# --- Module 3: Scoring (4 dims x 25 pts = 100) ---
def _score_boundary(silences, time_point, total_duration, is_start):
    """Score segment boundary clarity (start or end), return 0-25"""
    if is_start:
        nearby = [s for s in silences if abs(s[1] - time_point) < 0.1]
    else:
        nearby = [s for s in silences if abs(s[0] - time_point) < 0.1]

    if nearby:
        dur = nearby[0][1] - nearby[0][0]
        if dur >= 1.0: return 25
        if dur >= 0.5: return 20
        return 10

    if is_start and time_point < 0.5:
        return 15
    if not is_start and abs(time_point - total_duration) < 0.5:
        return 15
    return 5

def score_segment(seg, silences, total_duration):
    """4-dimension scoring: clear start/end + mid fluency + natural rhythm"""
    seg.score_start = _score_boundary(silences, seg.start_time, total_duration, is_start=True)
    seg.score_end = _score_boundary(silences, seg.end_time, total_duration, is_start=False)

    # Mid fluency (25 pts)
    internal = [s for s in silences
                if s[0] > seg.start_time + 0.1 and s[1] < seg.end_time - 0.1]
    seg.internal_silences = internal
    seg.interruption_count = len(internal)
    seg.interruption_duration = sum(e - s for s, e in internal)

    if seg.interruption_count == 0:      seg.score_fluency = 25
    elif seg.interruption_count <= 2:    seg.score_fluency = 20
    elif seg.interruption_count <= 4:    seg.score_fluency = 15
    else: seg.score_fluency = max(5, 25 - seg.interruption_count * 3)

    # Natural rhythm (25 pts): pause ratio (15) + max single pause (10) + short-segment cap
    score_rhythm = 0
    if seg.duration > 0:
        ratio = seg.interruption_duration / seg.duration
        score_rhythm += 15 if ratio < 0.05 else 12 if ratio < 0.10 else 8 if ratio < 0.20 else 4

        max_pause = max((e - s for s, e in internal), default=0)
        score_rhythm += 10 if max_pause < 0.8 else 7 if max_pause < 1.5 else 4 if max_pause < 2.5 else 0

        if seg.duration < 8:       score_rhythm = min(score_rhythm, 15)
        elif seg.duration < 15:    score_rhythm = min(score_rhythm, 20)

    seg.score_rhythm = score_rhythm
    seg.total_score = seg.score_start + seg.score_end + seg.score_fluency + seg.score_rhythm
    return seg

# --- Module 4: Transcription (Whisper CLI) ---
def transcribe_segment(video_path, seg, work_dir):
    """Extract segment audio and transcribe via Whisper"""
    audio_path = os.path.join(work_dir, f"segment_{seg.index}.wav")
    subprocess.run([
        "ffmpeg", "-y", "-i", video_path,
        "-ss", str(seg.start_time), "-to", str(seg.end_time),
        "-vn", "-ar", "16000", "-ac", "1", audio_path
    ], capture_output=True, text=True)

    if not os.path.exists(audio_path):
        print(f"    ⚠️  Audio extraction failed: segment_{seg.index}")
        return ""
    try:
        subprocess.run([
            "python3", "-m", "whisper", audio_path,
            "--model", "small", "--language", "zh",
            "--output_format", "txt", "--output_dir", work_dir
        ], capture_output=True, text=True, timeout=120)
        txt_path = audio_path.replace('.wav', '.txt')
        if os.path.exists(txt_path):
            with open(txt_path, 'r', encoding='utf-8') as f:
                return f.read().strip()
        print(f"    ⚠️  Transcript file not generated: segment_{seg.index}")
        return ""
    except Exception as e:
        print(f"    ⚠️  Transcription failed: segment_{seg.index}: {e}")
        return ""

# --- Module 5: Fluency analysis ---
def analyze_fluency(transcript):
    """Analyze transcript, return (repeat_count, stutter_count, is_natural_end, is_interrupted)"""
    if not transcript:
        return 0, 0, False, False

    text = re.sub(r'(?i)\bwhisper\b', '', transcript.strip()).strip()

    # Sliding-window phrase repeat detection (2-4 char phrases in 10-char window)
    text_clean = re.sub(r'[^\w]', '', text)
    repeat_count, i, window = 0, 0, 10
    while i < len(text_clean) - 2:
        found = False
        for length in [4, 3, 2]:
            if i + length > len(text_clean):
                continue
            chunk = text_clean[i:i+length]
            area = text_clean[i+length : i+length+window]
            if chunk in area:
                repeat_count += 1
                i += length + area.index(chunk) + length
                found = True
                break
        if not found:
            i += 1

    # Stutter detection (patterns for Chinese: 嗯啊呃=um/uh, 那个=that, 就是说=I mean, ellipsis)
    stutter_count = sum(len(re.findall(p, text)) for p in [r'[嗯啊呃]', r'那个', r'就是说', r'\.{2,}', r'…'])

    # Sudden interruption (Chinese connective/incomplete markers)
    interrupt_re = r'(的时候|然后|但是|如果|因为|而且|所以|就是|其实|那么|或者|并且|还是|不过|包括|比如说|另外|接下来|还有就是|就是说)$'
    is_interrupted = bool(re.search(interrupt_re, text))

    # Natural end detection
    has_punctuation = bool(re.search(r'[。！？]$', text))
    is_connective_end = bool(re.search(interrupt_re, text))
    # Natural end patterns (Chinese: questions, summaries, farewells)
    special_natural_patterns = [
        r'怎么[^。！？]*[呢？]$', r'什么[^。！？]*[呢？]$', r'为什么[^。！？]*[呢？]$',
        r'就是这样[。！？]*$', r'其实有很多[的。]*$',
        r'拜拜[^\w]*$', r'再见[^\w]*$', r'今天就到这[^\w]*$',
        r'分享给大家[^\w]*$', r'希望对你[也]*有帮助[^\w]*$',
    ]
    is_natural_end = (has_punctuation and not is_connective_end) or any(re.search(p, text) for p in special_natural_patterns)
    if is_interrupted:
        is_natural_end = False

    return repeat_count, stutter_count, is_natural_end, is_interrupted

# --- Module 6: Adjusted score ---
def calculate_adjusted_score(seg):
    """Apply fluency penalties/bonuses to base score, normalize to 0-100"""
    adjusted = seg.total_score
    duration_factor = max(1.0, seg.duration / 30.0)
    adjusted -= (seg.repeat_count / duration_factor) * CONFIG['penalty_repeat']
    adjusted -= (seg.stutter_count / duration_factor) * CONFIG['penalty_stutter']
    if seg.is_interrupted:
        adjusted -= CONFIG['penalty_interrupt']
    if seg.is_natural_end:
        adjusted += CONFIG['bonus_natural_end']
    if seg.is_natural_end and not seg.is_interrupted:
        adjusted += max(0, CONFIG['bonus_completeness_max'] * (1 - abs(seg.duration - 60) / 60))
    return max(0, min(100, adjusted))

# --- Module 7: Content dedup (generic grouping) ---
def _find_duplicate_groups(items, get_text):
    """Group items by text similarity (pairwise comparison)"""
    groups = []
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            t1, t2 = get_text(items[i]), get_text(items[j])
            if not t1 or not t2:
                continue
            if difflib.SequenceMatcher(None, t1, t2).ratio() > CONFIG['duplicate_threshold']:
                merged = False
                for group in groups:
                    if i in group or j in group:
                        group.update([i, j])
                        merged = True
                        break
                if not merged:
                    groups.append({i, j})
    return groups

def check_duplicate_content(candidates):
    """Within-video dedup: group candidates, keep best per group, mark rest as duplicate"""
    groups = _find_duplicate_groups(candidates, lambda s: s.transcript)
    for group in groups:
        for i, j in [(i, j) for i in group for j in group if i < j]:
            print(f"    ⚠️  segment_{candidates[i].index} and segment_{candidates[j].index} content similar")
        best = max(group, key=lambda idx: (candidates[idx].is_natural_end, candidates[idx].adjusted_score, candidates[idx].index))
        for idx in group:
            if idx != best:
                candidates[idx].is_duplicate = True
                candidates[idx].duplicate_with.append(candidates[best].index)
    return candidates

def cross_video_dedup(clips):
    """Cross-video dedup: compare clip transcripts, mark duplicates"""
    if len(clips) < 2:
        return clips
    groups = _find_duplicate_groups(clips, lambda c: c.transcript)
    for group in groups:
        for i, j in [(i, j) for i in group for j in group if i < j]:
            print(f"    ⚠️  {clips[i].video_name} and {clips[j].video_name} content similar")
        best = max(group, key=lambda idx: (clips[idx].is_natural_end, clips[idx].adjusted_score, clips[idx].video_name))
        for idx in group:
            if idx != best:
                clips[idx].is_cross_duplicate = True
                clips[idx].duplicate_of = clips[best].video_name
    return clips

# --- Module 8: Layered selection ---
def select_best_segment(candidates):
    """Layered selection: natural end -> fluency -> adjusted score -> duration/index"""
    if not candidates:
        return None

    pool = [c for c in candidates if not c.is_duplicate] or list(candidates)
    all_unnatural = not any(s.is_natural_end for s in pool)

    natural_end = [s for s in pool if s.is_natural_end]
    if natural_end:
        pool = natural_end

    pool.sort(key=lambda s: (s.stutter_count + s.repeat_count) / max(1.0, s.duration / 30.0))
    best_rate = (pool[0].stutter_count + pool[0].repeat_count) / max(1.0, pool[0].duration / 30.0)
    pool = [s for s in pool if ((s.stutter_count + s.repeat_count) / max(1.0, s.duration / 30.0)) - best_rate <= 1.5]

    pool.sort(key=lambda s: s.adjusted_score, reverse=True)
    pool = [s for s in pool if s.adjusted_score == pool[0].adjusted_score]

    if len(pool) > 1:
        if all_unnatural:
            pool.sort(key=lambda s: s.index, reverse=True)
        else:
            pool.sort(key=lambda s: s.duration, reverse=True)

    return pool[0]

# --- Module 9: FFmpeg operations ---
def clip_segment(video_path, seg, output_path):
    """Clip target segment with FFmpeg (with start/end buffer)"""
    start = max(0, seg.start_time - CONFIG['buffer_start'])
    end = seg.end_time + CONFIG['buffer_end']
    cmd = [
        "ffmpeg", "-y", "-i", video_path,
        "-ss", str(start), "-to", str(end),
        "-c:v", "libx264", "-crf", str(CONFIG['crf']), "-preset", CONFIG['preset'],
        "-c:a", "aac", "-b:a", CONFIG['audio_bitrate'],
        output_path
    ]
    return subprocess.run(cmd, capture_output=True, text=True).returncode == 0

def concat_videos(clip_paths, output_path):
    """Concatenate video list using FFmpeg concat protocol"""
    list_file = output_path + ".list.txt"
    with open(list_file, 'w') as f:
        for p in clip_paths:
            f.write(f"file '{os.path.abspath(p)}'\n")
    cmd = [
        "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file,
        "-c:v", "libx264", "-crf", str(CONFIG['crf']), "-preset", CONFIG['preset'],
        "-c:a", "aac", "-b:a", CONFIG['audio_bitrate'],
        output_path
    ]
    ok = subprocess.run(cmd, capture_output=True, text=True).returncode == 0
    if os.path.exists(list_file):
        os.remove(list_file)
    return ok

# --- Scenario A: Single video ---
def process_single_video(video_path, output_dir, work_dir, batch_mode=False):
    """
    Process single video, return ClipInfo; None on failure.
    batch_mode=True skips individual report (Scenario B generates one).
    """
    video_name = os.path.splitext(os.path.basename(video_path))[0]
    video_work = os.path.join(work_dir, video_name)
    os.makedirs(video_work, exist_ok=True)

    print(f"\n{'='*60}")
    print(f"  Processing: {video_name}" if batch_mode else f"  Video Auto Editor v4.7 - Scenario A\n  Input: {video_path}")
    print(f"{'='*60}\n")

    # Step 1: Get video info
    print("📋 Step 1: Getting video info...")
    cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", video_path]
    result = subprocess.run(cmd, capture_output=True, text=True)
    try:
        total_duration = float(json.loads(result.stdout)['format']['duration'])
    except Exception as e:
        print(f"   ❌ Failed to get video info: {e}")
        return None
    print(f"   Duration: {total_duration:.1f}s ({total_duration/60:.1f}min)")

    # Step 2: Silence detection
    print("\n🔇 Step 2: Silence detection...")
    silences = detect_silence(video_path)
    print(f"   Detected {len(silences)} silence spans")

    # Step 3: Segment identification
    print("\n📝 Step 3: Segment identification...")
    segments = identify_segments(silences, total_duration)
    print(f"   Identified {len(segments)} segments")

    # Step 4: Scoring
    print("\n⭐ Step 4: Scoring...")
    for seg in segments:
        score_segment(seg, silences, total_duration)
        print(f"   segment_{seg.index}: {seg.start_time:.1f}s-{seg.end_time:.1f}s "
              f"({seg.duration:.1f}s) score={seg.total_score}")

    # Step 5: Filter candidates
    print(f"\n🔍 Step 5: Filtering candidates (min_score={CONFIG['min_score']}, min_duration={CONFIG['min_duration']}s)...")
    candidates = [s for s in segments if s.total_score >= CONFIG['min_score'] and s.duration >= CONFIG['min_duration']]
    print(f"   {len(candidates)} candidate segments")

    if not candidates:
        print("\n⚠️  No candidates meet criteria, lowering standards...")
        candidates = sorted(segments, key=lambda s: s.total_score, reverse=True)[:5]
        print(f"   Selected top {len(candidates)} segments by score")

    # Step 6: Transcription
    print("\n🎤 Step 6: Transcribing candidates...")
    whisper_available = True
    try:
        if subprocess.run(["python3", "-m", "whisper", "--help"], capture_output=True, text=True, timeout=10).returncode != 0:
            whisper_available = False
    except Exception:
        whisper_available = False

    if whisper_available:
        for seg in candidates:
            print(f"   Transcribing segment_{seg.index}...")
            seg.transcript = transcribe_segment(video_path, seg, video_work)
            if seg.transcript:
                preview = seg.transcript[:50] + "..." if len(seg.transcript) > 50 else seg.transcript
                print(f"   ✅ [{preview}]")
    else:
        print("   ⚠️  Whisper not installed, skipping transcription, using audio-only scoring")

    # Step 7: Fluency analysis
    print("\n📊 Step 7: Fluency analysis...")
    for seg in candidates:
        if seg.transcript:
            seg.repeat_count, seg.stutter_count, seg.is_natural_end, seg.is_interrupted = analyze_fluency(seg.transcript)
        seg.adjusted_score = calculate_adjusted_score(seg)
        status = (" ✅natural end" if seg.is_natural_end else "") + (" ❌interrupted" if seg.is_interrupted else "")
        print(f"   segment_{seg.index}: base={seg.total_score} adjusted={seg.adjusted_score:.1f}"
              f" repeat={seg.repeat_count} stutter={seg.stutter_count}{status}")

    # Step 8: Within-video duplicate detection
    print("\n🔄 Step 8: Duplicate content detection...")
    candidates = check_duplicate_content(candidates)
    print(f"   Marked {sum(1 for c in candidates if c.is_duplicate)} duplicate segments")

    # Step 9: Layered selection
    print("\n🏆 Step 9: Selecting best segment...")
    best = select_best_segment(candidates)
    if not best:
        print("   ❌ Cannot select best segment")
        return None

    print(f"   ✅ Best: segment_{best.index} | "
          f"{best.start_time:.1f}-{best.end_time:.1f}s ({best.duration:.1f}s) | "
          f"adjusted={best.adjusted_score:.1f} | natural_end={'yes' if best.is_natural_end else 'no'}")

    # Step 10: Clip output
    print(f"\n✂️  Step 10: Clipping output...")
    output_path = os.path.join(output_dir, f"{video_name}_clip.mp4")
    if not clip_segment(video_path, best, output_path):
        print(f"   ❌ Failed to clip")
        return None
    print(f"   ✅ Output: {output_path}")

    # Generate individual report only when not in batch mode
    if not batch_mode:
        report_path = os.path.join(output_dir, f"{video_name}_report.md")
        with open(report_path, 'w', encoding='utf-8') as f:
            f.write(f"# {video_name} Clip Report\n\n")
            f.write(f"**Version**: v4.7\n")
            f.write(f"**Processed**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n")
            f.write(f"## Video Info\n\n")
            f.write(f"- Duration: {total_duration:.1f}s ({total_duration/60:.1f}min)\n")
            f.write(f"- Silence spans: {len(silences)}\n- Segments: {len(segments)}\n- Candidates: {len(candidates)}\n\n")
            f.write(f"## Candidate Comparison\n\n")
            f.write(f"| Segment | Time Range | Duration | Base | Adjusted | Natural End | Duplicate | Selected |\n")
            f.write(f"|---------|------------|----------|------|----------|-------------|-----------|----------|\n")
            for c in candidates:
                f.write(f"| seg_{c.index} | {c.start_time:.1f}-{c.end_time:.1f}s | "
                        f"{c.duration:.1f}s | {c.total_score} | {c.adjusted_score:.1f} | "
                        f"{'yes' if c.is_natural_end else 'no'} | "
                        f"{'yes' if c.is_duplicate else ''} | "
                        f"{'✅' if c.index == best.index else ''} |\n")
            f.write(f"\n## Final Selection\n\n")
            f.write(f"- **Segment**: segment_{best.index}\n")
            f.write(f"- **Time**: {best.start_time:.1f}s - {best.end_time:.1f}s\n")
            f.write(f"- **Duration**: {best.duration:.1f}s\n")
            f.write(f"- **Adjusted Score**: {best.adjusted_score:.1f}\n")
            if best.transcript:
                f.write(f"- **Transcript**: {best.transcript}\n")
        print(f"   📄 Report: {report_path}")

    return ClipInfo(
        video_name=video_name, clip_path=output_path,
        transcript=best.transcript, adjusted_score=best.adjusted_score,
        is_natural_end=best.is_natural_end, duration=best.duration,
    )

# --- Scenario B: Batch -> cross-video dedup -> concatenation ---
def process_batch(input_dir, output_dir, work_dir):
    """Scenario B: Output final concatenated video + single batch report"""
    video_files = sorted(
        glob.glob(os.path.join(input_dir, "*.MTS")) +
        glob.glob(os.path.join(input_dir, "*.mp4")) +
        glob.glob(os.path.join(input_dir, "*.mov"))
    )
    if not video_files:
        print("❌ No video files found"); return

    print(f"\n{'='*60}")
    print(f"  Video Auto Editor v4.7 - Scenario B (Batch)")
    print(f"  Input directory: {input_dir} ({len(video_files)} videos)")
    print(f"{'='*60}\n")

    # Phase 1: Process each video (batch_mode=True, no individual reports)
    clips = []
    for vf in video_files:
        clip = process_single_video(vf, output_dir, work_dir, batch_mode=True)
        if clip:
            clips.append(clip)

    if not clips:
        print("❌ No videos processed successfully"); return

    # Phase 2: Cross-video dedup
    print(f"\n{'='*60}")
    print(f"  🔄 Cross-video dedup check ({len(clips)} clips)")
    print(f"{'='*60}\n")

    clips = cross_video_dedup(clips)
    kept = [c for c in clips if not c.is_cross_duplicate]
    removed = [c for c in clips if c.is_cross_duplicate]

    for c in removed:
        print(f"   ❌ Remove {c.video_name} (duplicate of {c.duplicate_of}, adjusted {c.adjusted_score:.1f})")
    for c in kept:
        print(f"   ✅ Keep {c.video_name} (adjusted {c.adjusted_score:.1f})")
    print(f"\n   Dedup result: {len(clips)} -> {len(kept)} clips")

    # Phase 3: Concatenation
    print(f"\n{'='*60}")
    print(f"  🎬 Concatenating {len(kept)} clips")
    print(f"{'='*60}\n")

    final_path = os.path.join(output_dir, f"final_concat_{datetime.datetime.now().strftime('%Y%m%d_%H%M')}.mp4")
    if not concat_videos([c.clip_path for c in kept], final_path):
        print("   ❌ Concatenation failed"); return
    print(f"   ✅ Final video: {final_path}")

    # Phase 4: Clean intermediate files (individual clips + temp audio)
    for c in clips:
        if os.path.exists(c.clip_path):
            os.remove(c.clip_path)
    if os.path.exists(work_dir):
        shutil.rmtree(work_dir, ignore_errors=True)

    # Phase 5: Generate single batch report
    report_path = os.path.join(output_dir, "batch_report.md")
    total_dur = sum(c.duration for c in kept)
    with open(report_path, 'w', encoding='utf-8') as f:
        f.write(f"# Batch Processing Report\n\n")
        f.write(f"**Version**: v4.7\n")
        f.write(f"**Processed**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n")

        if removed:
            f.write(f"## Cross-Video Dedup\n\n")
            f.write(f"| Video | Adjusted | Natural End | Decision | Reason |\n")
            f.write(f"|-------|----------|--------------|----------|--------|\n")
            for c in clips:
                decision = "❌ Remove" if c.is_cross_duplicate else "✅ Keep"
                reason = f"duplicate of {c.duplicate_of}" if c.is_cross_duplicate else ""
                f.write(f"| {c.video_name} | {c.adjusted_score:.1f} | "
                        f"{'yes' if c.is_natural_end else 'no'} | {decision} | {reason} |\n")
            f.write(f"\n")

        f.write(f"## Final Concatenation ({len(kept)} clips)\n\n")
        f.write(f"| # | Video | Duration | Adjusted | Natural End | Transcript Summary |\n")
        f.write(f"|---|-------|----------|----------|-------------|--------------------|\n")
        for i, c in enumerate(kept, 1):
            summary = (c.transcript[:40] + "...") if c.transcript and len(c.transcript) > 40 else (c.transcript or "—")
            f.write(f"| {i} | {c.video_name} | {c.duration:.1f}s | "
                    f"{c.adjusted_score:.1f} | {'yes' if c.is_natural_end else 'no'} | {summary} |\n")
        f.write(f"\n**Total duration**: {total_dur:.1f}s ({total_dur/60:.1f}min)\n")
        f.write(f"\n**Output file**: `{final_path}`\n")

    print(f"   📄 Report: {report_path}")
    print(f"\n{'='*60}")
    print(f"  Batch processing complete! ({len(kept)}/{len(clips)} clips)")
    print(f"{'='*60}\n")

# --- Main entry ---
def main():
    if len(sys.argv) >= 2 and os.path.isdir(sys.argv[1]):
        input_dir = sys.argv[1]
        output_dir = sys.argv[2] if len(sys.argv) > 2 else "./output"
        work_dir = sys.argv[3] if len(sys.argv) > 3 else "./video_work"
        os.makedirs(output_dir, exist_ok=True)
        os.makedirs(work_dir, exist_ok=True)
        process_batch(input_dir, output_dir, work_dir)
        return

    video_path = sys.argv[1] if len(sys.argv) > 1 else "02047.MTS"
    output_dir = sys.argv[2] if len(sys.argv) > 2 else "./output"
    work_dir = "./video_work"
    os.makedirs(output_dir, exist_ok=True)
    os.makedirs(work_dir, exist_ok=True)

    clip = process_single_video(video_path, output_dir, work_dir)
    if clip:
        print(f"  Scenario A complete: {clip.clip_path}")
    else:
        print("  ❌ Processing failed")

if __name__ == "__main__":
    main()


path: README.md
content: # 视频自动剪辑系统 v4.7

基于规则和 AI 的自动化视频粗剪工具。自动从原始拍摄素材中识别最佳片段，完成剪辑与拼接。

> **English**: See [README_EN.md](README_EN.md) for English documentation.

---

## 功能概述

- **场景A - 单视频粗剪**：从一个视频中自动选出最佳片段
- **场景B - 批量粗剪拼接**：批量处理多个视频，自动跨视频去重后拼接为一个完整视频
- **智能评分**：4维度评分（清晰开始/结束、中间流畅、节奏自然）+ 流畅度分析
- **内容去重**：基于转录文本的相似度检测，视频内 + 跨视频双层去重
- **自动报告**：每次处理自动生成 Markdown 格式的详细报告

---

## 环境要求

- **Python** 3.8+
- **FFmpeg**（含 ffprobe）
- **openai-whisper**

### 安装依赖

```bash
# macOS
brew install ffmpeg
pip install openai-whisper

# Ubuntu / Debian
sudo apt install ffmpeg
pip install openai-whisper
```

---

## 快速开始

### 场景A：单视频粗剪

传入一个**视频文件路径**：

```bash
python3 video_editor_auto_v4.6.py ./视频.MTS ./output
```

输出：

```
output/
├── 视频_粗剪.mp4    # 剪辑后的最佳片段
└── 视频_报告.md     # 处理报告
```

### 场景B：批量粗剪 + 去重 + 拼接

传入一个**文件夹路径**（自动识别为批量模式）：

```bash
python3 video_editor_auto_v4.6.py ./Video ./output
```

输出（只有两个文件，中间产物自动清理）：

```
output/
├── 最终拼接_20260311_1905.mp4  # 去重后的拼接视频
└── 批量处理报告.md              # 批量报告（含各片段详情 + 去重决策）
```

### 命令格式

```
python3 video_editor_auto_v4.6.py <输入> [输出目录] [工作目录]
```

| 参数 | 说明 | 默认值 |
|------|------|--------|
| `<输入>` | 视频文件路径（场景A）或文件夹路径（场景B） | 必填 |
| `[输出目录]` | 粗剪视频和报告的输出位置 | `./output` |
| `[工作目录]` | 临时音频文件等中间产物 | `./video_work` |

支持的视频格式：`.MTS`、`.mp4`、`.mov`

---

## 处理流程

### 场景A（单视频）

```
输入视频 → 静音检测 → 段落识别 → 4维评分 → 筛选候选
→ Whisper转录 → 流畅度分析 → 视频内去重 → 分层筛选 → 剪辑输出
```

### 场景B（批量）

```
输入目录 → 逐个执行场景A(不生成单独报告)
→ 跨视频去重 → 按文件名顺序拼接
→ 清理中间文件 → 生成唯一一份批量报告
```

---

## 配置参数

所有参数在脚本顶部的 `CONFIG` 字典中配置：

```python
CONFIG = {
    # 静音检测
    "silence_noise": -30,           # dB, 越小越严格
    "silence_duration": 0.8,        # 秒, 静音最小时长

    # 筛选标准
    "min_score": 90,                # 基础分最低要求 (满分100)
    "min_duration": 15,             # 段落最低时长 (秒)

    # 剪辑缓冲
    "buffer_start": 1,              # 开始前缓冲 (秒)
    "buffer_end": 3,                # 结束后缓冲 (秒)

    # 编码参数
    "crf": 18,                      # 视频质量 (18=视觉无损, 23=默认)
    "preset": "fast",               # 编码速度
    "audio_bitrate": "192k",        # 音频码率

    # 调整分权重
    "penalty_repeat": 5,            # 每次重复扣分
    "penalty_stutter": 3,           # 每次卡顿扣分
    "penalty_interrupt": 10,        # 突然中断扣分
    "bonus_natural_end": 5,         # 自然结束加分
    "bonus_completeness_max": 3,    # 完整度加分上限

    # 重复检测
    "duplicate_threshold": 0.7,     # 内容相似度阈值 (0-1)
}
```

### 调参建议

| 场景 | 参数 | 建议值 |
|------|------|--------|
| 环境噪音大 | `silence_noise` | 改为 `-35` |
| 段落被切太碎 | `silence_duration` | 改为 `1.0` |
| 想要更多候选 | `min_score` | 改为 `85` |
| 想要更短片段 | `min_duration` | 改为 `10` |
| 追求画质 | `crf` | 改为 `15`（文件更大） |

---

## 评分体系

### 基础分（4维 × 25分 = 100分）

| 维度 | 满分 | 评判标准 |
|------|------|---------|
| 清晰的开始 | 25 | 段落前是否有足够的静音间隔 |
| 清晰的结束 | 25 | 段落后是否有足够的静音间隔 |
| 中间流畅 | 25 | 段落内部的中断次数越少越好 |
| 节奏自然 | 25 | 停顿占比低 + 无过长单次停顿 + 非极短片段 |

### 调整分（0-100）

在基础分之上，根据转录文本分析进行加减分：

| 项目 | 分值 | 说明 |
|------|------|------|
| 重复扣分 | -5/次 | "重说"类口误（按每30秒频率归一化） |
| 卡顿扣分 | -3/次 | 嗯、啊、呃等语气词 |
| 突然中断扣分 | -10 | 以"然后"、"但是"等连接词结尾 |
| 自然结束加分 | +5 | 完整句子、问题、总结结尾 |
| 完整度加分 | +0~3 | 自然结束 + 时长接近60秒 |

### 分层筛选（选择最佳段落）

不是单纯选分数最高的，而是按优先级逐层筛选：

```
第1层: 优先选自然结束的段落
第2层: 按流畅度排序（容差1.5次/30秒）
第3层: 按调整分排序
第4层: 平局时 → 都没说完选最后一段；有说完的选最长的
```

### 去重规则

视频内去重和跨视频去重使用相同的选择规则：

```
自然结束优先 → 调整分高优先 → 索引/文件名靠后优先
```

---

## 常见问题

### Q: 没有检测到静音段？

环境噪音可能导致误判。尝试将 `silence_noise` 从 `-30` 调低到 `-35`。

### Q: 段落被切得太碎？

静音时长阈值可能太短。将 `silence_duration` 从 `0.8` 调高到 `1.0` 或 `1.5`。

### Q: 选择了看起来评分不是最高的段落？

系统使用**分层筛选**而非单纯比较分数。自然结束 > 流畅度 > 调整分 > 时长。一个95分但突然中断的段落可能不如一个90分但自然结束的段落。

### Q: Whisper 转录不准确？

当前默认使用 `small` 模型以平衡速度和准确率。如果转录质量不佳：
- 脚本中 `transcribe_segment` 函数里将 `--model small` 改为 `--model medium` 或 `--model large`
- `medium` 模型中文准确率约 85%，推荐在资源允许时使用

### Q: 跨视频去重时如何决定保留哪个？

选择规则：自然结束优先 → 调整分高优先 → 文件名靠后优先（通常是最后一次拍摄，状态最好）。

### Q: 如何查看详细处理报告？

- 场景A：`output/<视频名>_报告.md`
- 场景B：`output/批量处理报告.md`（含各片段详情、转录摘要、跨视频去重决策）
  - 场景B不会生成单独的中间报告和粗剪文件，拼接完成后自动清理

---

## 项目结构

```
video_editor_v4.6_release/
├── video_editor_auto_v4.6.py   # 主程序
├── README.md                   # 本文档（中文）
├── README_EN.md                # 英文使用说明
├── 代码文档.md                  # 技术文档（中文）
├── CODE_DOCUMENTATION.md       # 技术文档（英文）
├── requirements.txt            # Python 依赖
├── LICENSE                     # GPL v3 许可证
└── .gitignore                  # Git 忽略规则
```

---

## 技术文档

如需了解各模块的详细实现、数据结构定义、算法说明和扩展指南，请参阅 `代码文档.md`。

---

## 许可证 (License)

本项目采用 **GPL v3** 许可证。若你将本软件或其衍生作品用于商业用途，你必须将你的产品同样以开源方式发布（遵守 GPL v3 的传染性条款）。

This project is licensed under **GPL v3**. If you use this software or its derivatives for commercial purposes, you must release your product as open source under the same license.

详见 [LICENSE](LICENSE) 文件。

---

**版本**: v4.7 | **最后更新**: 2026-03-11


path: CODE_DOCUMENTATION.md
content: # Video Auto Editor v4.7 - Technical Documentation

**Version**: v4.7
**Last Updated**: 2026-03-11

> This document is for developers. It describes the system architecture, module implementations, data structures, and extension guidelines. The system is designed for **single-person / talking-to-camera (A'Roll) content**; segmentation and fluency analysis assume monologue-style speech. 

---

## Table of Contents

1. [System Architecture](#system-architecture)
2. [Data Structures](#data-structures)
3. [Core Modules](#core-modules)
4. [API Reference](#api-reference)
5. [Extension Guide](#extension-guide)

---

## System Architecture

### Overall Flow

```
┌─────────────────────────────────────────────────┐
│              Main Entry (main)                   │
│  - Parse arguments                               │
│  - Detect scenario: file=Scenario A, dir=Scenario B │
└────────────────┬────────────────────────────────┘
                 │
        ┌────────┴────────┐
        │                 │
   Scenario A: Single   Scenario B: Batch
        │                 │
        ▼                 ▼
┌──────────────┐   ┌──────────────┐
│ Silence      │   │ Run Scenario A│
│ detection    │   │ per video     │
│ Segment      │   └──────┬───────┘
│ identification│         │
│ 4-dim scoring│   ┌──────▼───────┐
│ Candidate    │   │ Cross-video   │
│ filtering    │   │ deduplication │
│ Whisper      │   └──────┬───────┘
│ transcription│         │
│ Fluency      │   ┌──────▼───────┐
│ analysis     │   │ Concatenate  │
│ Within-video │   └──────┬───────┘
│ dedup        │         │
│ Layered      │   ┌──────▼───────┐
│ selection    │   │ Batch report  │
│ Clip output  │   └──────────────┘
│ Report       │
└──────────────┘
```

### Module List

| # | Module | Function | Description |
|---|--------|----------|-------------|
| 1 | Silence detection | `detect_silence()` | FFmpeg silencedetect filter |
| 2 | Segment identification | `identify_segments()` | Split non-silent intervals by silence |
| 3 | Scoring | `score_segment()` + `_score_boundary()` | 4 dims × 25 pts = 100 |
| 4 | Transcription | `transcribe_segment()` | Whisper CLI (Chinese) |
| 5 | Fluency analysis | `analyze_fluency()` | Repeat/stutter/interrupt/natural-end detection |
| 6 | Adjusted score | `calculate_adjusted_score()` | Base + penalties/bonuses → 0-100 |
| 7 | Content dedup | `_find_duplicate_groups()` + `check_duplicate_content()` + `cross_video_dedup()` | Generic grouping, shared by within/cross-video |
| 8 | Layered selection | `select_best_segment()` | Natural end → Fluency → Adjusted score → Duration |
| 9 | FFmpeg ops | `clip_segment()` + `concat_videos()` | Clip + concatenate |

---

## Data Structures

### Segment

Represents one segment (non-silent interval) in a video:

```python
@dataclass
class Segment:
    index: int                    # Segment index
    start_time: float             # Start time (seconds)
    end_time: float               # End time (seconds)
    duration: float               # Duration (seconds)

    # Base scores (4 dims × 25 pts)
    score_start: float = 0        # Clear start
    score_end: float = 0          # Clear end
    score_fluency: float = 0      # Mid fluency
    score_rhythm: float = 0       # Natural rhythm
    total_score: float = 0         # Base score total

    # Internal interruption info
    internal_silences: List[Tuple[float, float]]  # Internal silence spans
    interruption_count: int = 0                     # Interruption count
    interruption_duration: float = 0                # Total interruption duration

    # Transcription & fluency
    transcript: str = ""          # Whisper transcript
    repeat_count: int = 0         # Phrase repeat count
    stutter_count: int = 0        # Filler word count
    is_natural_end: bool = False  # Natural ending
    is_interrupted: bool = False  # Sudden interruption

    # Adjusted score & dedup
    adjusted_score: float = 0     # Adjusted score (0-100)
    is_duplicate: bool = False    # Marked as duplicate
    duplicate_with: List[int]     # Duplicate of which segments
```

### ClipInfo

Represents one video's rough-cut result, used for cross-video dedup:

```python
@dataclass
class ClipInfo:
    video_name: str               # Video filename (no extension)
    clip_path: str                # Clip output path
    transcript: str               # Best segment transcript
    adjusted_score: float         # Best segment adjusted score
    is_natural_end: bool          # Best segment natural end
    duration: float               # Best segment duration
    is_cross_duplicate: bool = False  # Marked by cross-video dedup
    duplicate_of: str = ""            # Duplicate of which video
```

---

## Core Modules

### Module 1: Silence Detection

```python
def detect_silence(video_path) -> List[Tuple[float, float]]
```

Uses FFmpeg `silencedetect` filter to detect silence spans.

**Parameters**:
- `silence_noise`: Silence threshold (dB), default -30, lower = stricter
- `silence_duration`: Minimum silence length (seconds), default 0.8

**Note**: FFmpeg outputs silence detection results to stderr.

---

### Module 2: Segment Identification

```python
def identify_segments(silences, total_duration) -> List[Segment]
```

Splits video into segments by silence:
1. First segment: video start → first silence start
2. Middle segments: previous silence end → next silence start (keep if ≥1s)
3. Last segment: last silence end → video end

---

### Module 3: Scoring System

```python
def score_segment(seg, silences, total_duration) -> Segment
```

4-dimension scoring, 25 pts each, total 100:

**Clear Start (25 pts)**:

| Condition | Score |
|-----------|-------|
| Pre-silence ≥ 1.0s | 25 |
| Pre-silence ≥ 0.5s | 20 |
| Pre-silence < 0.5s | 10 |
| At video start (< 0.5s) | 15 |
| Other | 5 |

**Clear End (25 pts)**: Same logic for post-silence.

**Mid Fluency (25 pts)**:

| Internal interruptions | Score |
|------------------------|-------|
| 0 | 25 |
| 1-2 | 20 |
| 3-4 | 15 |
| 5+ | max(5, 25 - count × 3) |

**Natural Rhythm (25 pts)**: Three components:
- Pause ratio (15 pts): < 5% → 15, decreasing
- Max single pause (10 pts): < 0.8s → 10, decreasing
- Short-segment cap: < 8s cap 15, < 15s cap 20

---

### Module 4: Transcription

```python
def transcribe_segment(video_path, seg, work_dir) -> str
```

1. Extract segment audio with FFmpeg (16kHz, mono, WAV)
2. Call Whisper CLI for Chinese transcription
3. If Whisper not installed, skip and use audio-only scoring

---

### Module 5: Fluency Analysis

```python
def analyze_fluency(transcript) -> Tuple[int, int, bool, bool]
```

Returns `(repeat_count, stutter_count, is_natural_end, is_interrupted)`.

**Repeat detection**: Sliding window, 2-4 char phrases repeated in next 10 chars.

**Stutter detection**: Matches:
- Single filler: `[嗯啊呃]` (um, uh, etc.)
- Filler phrases: `那个`, `就是说`
- Ellipsis: `...`, `…`

**Interruption detection**: Text ends with 20 connective/incomplete markers, e.g.:
`的时候 | 然后 | 但是 | 如果 | 因为 | 而且 | 所以 | 就是 | 其实 | 那么 | 或者 | 并且 | 还是 | 不过 | 包括 | 比如说 | 另外 | 接下来 | 还有就是 | 就是说`

**Natural end detection**: Any of:
- Ends with period/exclamation/question, and not with connective
- Matches special patterns: questions ("怎么…呢？"), summaries ("就是这样"), farewells ("拜拜", "再见", etc.)

---

### Module 6: Adjusted Score

```python
def calculate_adjusted_score(seg) -> float
```

```
adjusted = base_score
         - (repeat_count / duration_factor) × 5
         - (stutter_count / duration_factor) × 3
         - (interrupted ? 10 : 0)
         + (natural_end ? 5 : 0)
         + completeness_bonus (0~3, continuous, 60s optimal)
```

`duration_factor = max(1.0, segment_duration / 30.0)` to avoid long segments accumulating penalties.

Final value clamped to 0-100.

---

### Module 7: Content Deduplication (Generic Grouping)

Within-video and cross-video dedup share `_find_duplicate_groups()`:

```python
def _find_duplicate_groups(items, get_text) -> List[Set[int]]
```

1. Pairwise compare `SequenceMatcher` similarity of `get_text(item)`
2. Group items with similarity > threshold (default 0.7)

Upper functions select best in each group and mark others:
- `check_duplicate_content(candidates)`: Within-video, rule: natural end > adjusted score > later index
- `cross_video_dedup(clips)`: Cross-video, rule: natural end > adjusted score > later filename

---

### Module 8: Layered Selection

```python
def select_best_segment(candidates) -> Segment
```

From non-duplicate candidates, filter by priority:

| Layer | Rule | Description |
|-------|------|-------------|
| 1 | Natural end first | If any natural end, keep only those |
| 2 | Fluency sort | By stutter+repeat rate, tolerance 1.5 per 30s |
| 3 | Adjusted score | Keep highest |
| 4 | Tie-break | All incomplete → last; any complete → longest |

---

### Module 9: FFmpeg Operations

```python
def clip_segment(video_path, seg, output_path) -> bool
def concat_videos(clip_paths, output_path) -> bool
```

- `clip_segment`: Clip target segment with buffer, H.264 CRF 18 + AAC 192kbps
- `concat_videos`: FFmpeg concat protocol, re-encode for format consistency

---

## API Reference

### Scenario A

```python
def process_single_video(video_path: str, output_dir: str, work_dir: str, batch_mode: bool = False) -> Optional[ClipInfo]
```

Full pipeline (steps 1-10) for one video. Returns `ClipInfo` for Scenario B, or `None` on failure.

`batch_mode=True` skips individual reports (Scenario B generates one).

### Scenario B

```python
def process_batch(input_dir: str, output_dir: str, work_dir: str) -> None
```

Batch flow:
1. Scan input dir for `.MTS`/`.mp4`/`.mov`, sort by filename
2. Call `process_single_video(batch_mode=True)` per video
3. Call `cross_video_dedup`
4. Call `concat_videos` for kept clips
5. **Clean intermediate files** (individual clips + temp audio)
6. Generate single batch report (with transcript summaries)

Final output: concatenated video + batch report only.

### Main Entry

```python
def main() -> None
```

Auto-detects scenario from first argument:
- Directory → Scenario B
- File → Scenario A

---

## Extension Guide

### Add New Scoring Dimension

1. Add field to `Segment`:

```python
score_content: float = 0  # Content quality (20 pts)
```

2. Add scoring logic in `score_segment()`
3. Update `total_score` calculation
4. Note: New dimension may exceed 100 total; adjust `min_score` or normalize

### Add New Fluency Detection

1. Add detection in `analyze_fluency()` (e.g., speech rate)
2. Add corresponding penalty/bonus in `calculate_adjusted_score()`

### Add New Natural End Pattern

Append regex to `special_natural_patterns` in `analyze_fluency()`.

### Add New Video Format

Add new extension to `glob.glob` in `process_batch()` (e.g., `*.avi`).

---

**Version**: v4.7 | **Last Updated**: 2026-03-11


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