From 413fcf3d20985a81cc4f1dc33a8351d15e592f5a Mon Sep 17 00:00:00 2001 From: jerryxumaomao Date: Sat, 4 Jul 2026 15:25:12 +0800 Subject: [PATCH 1/4] fix: Windows subtitle path escaping in ffmpeg filter (backslashes eaten by filter parser) Co-Authored-By: Claude Fable 5 --- helpers/render.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/helpers/render.py b/helpers/render.py index 0d02cffa..f137bd30 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -537,7 +537,14 @@ def build_final_composite( # Subtitles LAST — Rule 1 if has_subs: - subs_abs = str(subtitles_path.resolve()).replace(":", r"\:").replace("'", r"\'") + # Windows paths must use forward slashes inside ffmpeg filter args — + # raw backslashes are eaten by the filter parser (D\:\Claude\... breaks). + subs_abs = ( + str(subtitles_path.resolve()) + .replace("\\", "/") + .replace(":", r"\:") + .replace("'", r"\'") + ) filter_parts.append( f"{current}subtitles='{subs_abs}':force_style='{SUB_FORCE_STYLE}'[outv]" ) From a8161f28bf3a9eb255ee80492af3ac14031cfd1b Mon Sep 17 00:00:00 2001 From: jerryxumaomao Date: Sat, 4 Jul 2026 16:01:39 +0800 Subject: [PATCH 2/4] feat: EDL-level subtitle_style override + CJK phrase-level caption chunking - edl.json 'subtitle_style' overrides SUB_FORCE_STYLE per project - Chinese ASR tokens (per-character) now group into phrase cues: break on CJK punctuation or 10 chars, no inter-char spaces, punctuation-only tokens act as break markers and are never displayed Co-Authored-By: Claude Fable 5 --- helpers/render.py | 69 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/helpers/render.py b/helpers/render.py index f137bd30..5a0cb0e9 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -287,6 +287,18 @@ def concat_segments(segment_paths: list[Path], out_path: Path, edit_dir: Path) - PUNCT_BREAK = set(".,!?;:") +# CJK support: Chinese ASR tokens are per-character; chunk by phrase, not by 2 tokens. +CJK_PUNCT = set(",。!?;:、…~—「」『』()") +CJK_RE = re.compile(r"[㐀-䶿一-鿿]") +MAX_CJK_CHARS_PER_CUE = 10 + + +def _is_cjk_text(text: str) -> bool: + return bool(CJK_RE.search(text)) + + +def _is_punct_only(text: str) -> bool: + return bool(text) and all(ch in PUNCT_BREAK or ch in CJK_PUNCT or ch.isspace() for ch in text) def _srt_timestamp(seconds: float) -> str: @@ -340,19 +352,36 @@ def build_master_srt(edl: dict, edit_dir: Path, out_path: Path) -> None: transcript = json.loads(tr_path.read_text()) words_in_seg = _words_in_range(transcript, seg_start, seg_end) - # Group into 2-word chunks, break on punctuation + # Group into cues. Latin: 2-word chunks. CJK: phrase-level — + # per-character ASR tokens accumulate until punctuation or MAX_CJK_CHARS_PER_CUE. chunks: list[list[dict]] = [] current: list[dict] = [] + cue_chars = 0 for w in words_in_seg: text = (w.get("text") or "").strip() if not text: continue + if _is_punct_only(text): + # Punctuation is a break marker, never displayed on its own + if current: + chunks.append(current) + current = [] + cue_chars = 0 + continue current.append(w) - # Break if the current text ends in punctuation or we hit 2 words - ends_in_punct = bool(text) and text[-1] in PUNCT_BREAK - if len(current) >= 2 or ends_in_punct: - chunks.append(current) - current = [] + cue_chars += len(text) + if _is_cjk_text(text): + ends_in_punct = text[-1] in PUNCT_BREAK or text[-1] in CJK_PUNCT + if cue_chars >= MAX_CJK_CHARS_PER_CUE or ends_in_punct: + chunks.append(current) + current = [] + cue_chars = 0 + else: + ends_in_punct = text[-1] in PUNCT_BREAK + if len(current) >= 2 or ends_in_punct: + chunks.append(current) + current = [] + cue_chars = 0 if current: chunks.append(current) @@ -363,11 +392,19 @@ def build_master_srt(edl: dict, edit_dir: Path, out_path: Path) -> None: out_end = max(0.0, local_end - seg_start) + seg_offset if out_end <= out_start: out_end = out_start + 0.4 - text = " ".join((w.get("text") or "").strip() for w in chunk) - text = re.sub(r"\s+", " ", text).strip() - # Strip trailing punctuation for cleaner uppercase look - text = text.rstrip(",;:") - text = text.upper() + raw_tokens = [(w.get("text") or "").strip() for w in chunk] + if any(_is_cjk_text(t) for t in raw_tokens): + # CJK: no spaces between characters, strip edge punctuation, no uppercasing + text = "".join(raw_tokens) + text = text.strip("".join(PUNCT_BREAK | CJK_PUNCT)) + else: + text = " ".join(raw_tokens) + text = re.sub(r"\s+", " ", text).strip() + # Strip trailing punctuation for cleaner uppercase look + text = text.rstrip(",;:") + text = text.upper() + if not text: + continue entries.append((out_start, out_end, text)) seg_offset += seg_duration @@ -499,6 +536,7 @@ def build_final_composite( subtitles_path: Path | None, out_path: Path, edit_dir: Path, + force_style: str | None = None, ) -> None: """Final pass: base → overlays (PTS-shifted) → subtitles LAST → out. @@ -545,8 +583,9 @@ def build_final_composite( .replace(":", r"\:") .replace("'", r"\'") ) + style = force_style or SUB_FORCE_STYLE filter_parts.append( - f"{current}subtitles='{subs_abs}':force_style='{SUB_FORCE_STYLE}'[outv]" + f"{current}subtitles='{subs_abs}':force_style='{style}'[outv]" ) out_label = "[outv]" else: @@ -647,13 +686,15 @@ def main() -> None: # 4. Composite (overlays + subtitles LAST) → intermediate (pre-loudnorm) path overlays = edl.get("overlays") or [] + # Optional per-project subtitle style override (ASS force_style string in edl.json) + sub_style = edl.get("subtitle_style") or None if args.no_loudnorm: # Composite directly to final output - build_final_composite(base_path, overlays, subs_path, out_path, edit_dir) + build_final_composite(base_path, overlays, subs_path, out_path, edit_dir, force_style=sub_style) else: # Composite to a temp file, then run loudnorm → final output tmp_composite = out_path.with_suffix(".prenorm.mp4") - build_final_composite(base_path, overlays, subs_path, tmp_composite, edit_dir) + build_final_composite(base_path, overlays, subs_path, tmp_composite, edit_dir, force_style=sub_style) print("loudness normalization → social-ready (-14 LUFS / -1 dBTP / LRA 11)") apply_loudnorm_two_pass(tmp_composite, out_path, preview=args.draft) tmp_composite.unlink(missing_ok=True) From c4011667f958ab6e77f18646c62015deea3a74de Mon Sep 17 00:00:00 2001 From: jerryxumaomao Date: Sat, 4 Jul 2026 16:31:21 +0800 Subject: [PATCH 3/4] fix: scale overlays to base resolution before compositing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg's overlay filter crops (not scales) oversized inputs — full-res overlay assets were cropped off-frame on preview/draft renders. Co-Authored-By: Claude Fable 5 --- helpers/render.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/helpers/render.py b/helpers/render.py index 5a0cb0e9..fc6cfd1d 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -288,7 +288,7 @@ def concat_segments(segment_paths: list[Path], out_path: Path, edit_dir: Path) - PUNCT_BREAK = set(".,!?;:") # CJK support: Chinese ASR tokens are per-character; chunk by phrase, not by 2 tokens. -CJK_PUNCT = set(",。!?;:、…~—「」『』()") +CJK_PUNCT = set(",。!?;:、…~—「」『』()“”‘’") CJK_RE = re.compile(r"[㐀-䶿一-鿿]") MAX_CJK_CHARS_PER_CUE = 10 @@ -394,9 +394,10 @@ def build_master_srt(edl: dict, edit_dir: Path, out_path: Path) -> None: out_end = out_start + 0.4 raw_tokens = [(w.get("text") or "").strip() for w in chunk] if any(_is_cjk_text(t) for t in raw_tokens): - # CJK: no spaces between characters, strip edge punctuation, no uppercasing + # CJK captions carry NO punctuation at all — cleaner on screen; + # phrase breaks already encode the rhythm text = "".join(raw_tokens) - text = text.strip("".join(PUNCT_BREAK | CJK_PUNCT)) + text = "".join(ch for ch in text if ch not in PUNCT_BREAK and ch not in CJK_PUNCT) else: text = " ".join(raw_tokens) text = re.sub(r"\s+", " ", text).strip() @@ -555,11 +556,22 @@ def build_final_composite( ov_path = resolve_path(ov["file"], edit_dir) inputs += ["-i", str(ov_path)] + # Overlays must match the base resolution — preview/draft bases are scaled + # down, and ffmpeg's overlay filter CROPS (not scales) oversized inputs. + probe = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0", str(base_path)], + capture_output=True, text=True, check=True, + ) + bw, bh = probe.stdout.strip().split("\n")[0].split(",")[:2] + filter_parts: list[str] = [] - # PTS-shift every overlay so its frame 0 lands at start_in_output + # Scale each overlay to the base, PTS-shift so its frame 0 lands at start_in_output for idx, ov in enumerate(overlays, start=1): t = float(ov["start_in_output"]) - filter_parts.append(f"[{idx}:v]setpts=PTS-STARTPTS+{t}/TB[a{idx}]") + filter_parts.append( + f"[{idx}:v]scale={bw}:{bh},setpts=PTS-STARTPTS+{t}/TB[a{idx}]" + ) # Chain overlays on top of base current = "[0:v]" From d2a94857b0c7fd1ed870f519f075adbe9b71c25c Mon Sep 17 00:00:00 2001 From: jerryxumaomao Date: Sun, 5 Jul 2026 10:44:19 +0800 Subject: [PATCH 4/4] feat: EDL-level audio_filter (pre-fade chain, e.g. denoise/EQ) + strip ALL punctuation from CJK captions Co-Authored-By: Claude Fable 5 --- helpers/render.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/helpers/render.py b/helpers/render.py index fc6cfd1d..10f44bb5 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -157,6 +157,7 @@ def extract_segment( out_path: Path, preview: bool = False, draft: bool = False, + audio_filter: str = "", ) -> None: """Extract a cut range as its own MP4 with grade + 30ms audio fades baked in. @@ -184,9 +185,12 @@ def extract_segment( vf_parts.append(grade_filter) vf = ",".join(vf_parts) - # 30ms audio fades at both edges (Rule 3) — prevent pops + # 30ms audio fades at both edges (Rule 3) — prevent pops. + # Optional per-project audio_filter (e.g. afftdn denoise) runs BEFORE the fades. fade_out_start = max(0.0, duration - 0.03) af = f"afade=t=in:st=0:d=0.03,afade=t=out:st={fade_out_start:.3f}:d=0.03" + if audio_filter: + af = f"{audio_filter},{af}" if draft: preset, crf = "ultrafast", "28" @@ -255,7 +259,8 @@ def extract_all_segments( print(f" [{i:02d}] {src_name} {start:7.2f}-{end:7.2f} ({duration:5.2f}s) {note}") if is_auto: print(f" grade: {seg_filter or '(none)'}") - extract_segment(src_path, start, duration, seg_filter, out_path, preview=preview, draft=draft) + extract_segment(src_path, start, duration, seg_filter, out_path, preview=preview, draft=draft, + audio_filter=edl.get("audio_filter") or "") seg_paths.append(out_path) return seg_paths