-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(render): add --vertical output with blur-pad and split-stack lay… #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
aebfac9
6ef61dd
6ae4e84
fc5f610
3756eed
0dbdc03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "permissions": { | ||
| "allow": [ | ||
| "Bash(ffprobe *)", | ||
| "PowerShell(Get-Process *)", | ||
| "Bash(npm config get *)", | ||
| "mcp__claude_ai_Google_Drive__search_files", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Google Drive MCP tool permissions ( Prompt for AI agents |
||
| "mcp__claude_ai_Google_Drive__get_file_metadata" | ||
| ] | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # checkpoint-summary.md — VideoUse | ||
|
|
||
| ## Status per 2026-07-11 (starter) | ||
|
|
||
| Belum ada sesi kerja yang terdokumentasi dengan workflow ini. | ||
|
|
||
| ### Konteks | ||
| - Kandidat tool untuk produksi klip campaign Content Rewards — status campaign terkini di `C:\claudeall\BRIEF.md` + `C:\claudeall\checkpoint-summary.md` (level workspace) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The Konteks section references absolute Windows paths ( Prompt for AI agents |
||
| - Kandidat pemakaian berikutnya: edit sinkron-beat untuk campaign 4D4M "Mad World" (2026-07-12) | ||
|
|
||
| ### Next task | ||
| - Isi bagian ini di akhir sesi kerja berikutnya di project ini | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # decisions.md — VideoUse (lokal) | ||
|
|
||
| **Konteks:** VideoUse adalah fork milik user (`wigu8989/video-use`) dari project open-source **video-use** — edit video lewat Claude Code: taruh footage mentah di folder, chat, dapat `final.mp4` (potong filler words, dead space, dll.). | ||
|
|
||
| ## Tech stack (untuk orientasi) | ||
| - Python package (`pyproject.toml`), integrasi via skills Claude Code (`SKILL.md`, folder `skills/`, `helpers/`) | ||
| - Project video user di `videouse-projects/` | ||
| - Konfigurasi API key di `.env` (contoh di `.env.example`) — jangan commit `.env` | ||
|
|
||
| ## Keputusan lokal | ||
| - Kandidat tool produksi untuk campaign clipping Content Rewards (edit klip 9:16 + subtitle) — belum dipakai untuk itu, baru kandidat | ||
| - Remote bernama `fork` (bukan `origin`) → push hanya ke fork sendiri, jangan ke upstream | ||
|
|
||
| _Dibuat 2026-07-11 sebagai starter; lengkapi saat sesi kerja berikutnya._ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -131,6 +131,18 @@ def is_hdr_source(video: Path) -> bool: | |
| return False | ||
|
|
||
|
|
||
| def get_source_dims(video: Path) -> tuple[int, int]: | ||
| """Return (width, height) of the first video stream.""" | ||
| out = subprocess.run( | ||
| ["ffprobe", "-v", "error", "-select_streams", "v:0", | ||
| "-show_entries", "stream=width,height", | ||
| "-of", "csv=p=0", str(video)], | ||
| capture_output=True, text=True, check=True, | ||
| ) | ||
| w, h = map(int, out.stdout.strip().split(",")) | ||
| return w, h | ||
|
|
||
|
|
||
| def is_portrait_source(video: Path) -> bool: | ||
| """Return True if the video's height > width (portrait / vertical).""" | ||
| try: | ||
|
|
@@ -157,12 +169,41 @@ def extract_segment( | |
| out_path: Path, | ||
| preview: bool = False, | ||
| draft: bool = False, | ||
| vertical: bool = False, | ||
| layout: str = "blur_pad", | ||
| split_faces: list | None = None, | ||
| ) -> None: | ||
| """Extract a cut range as its own MP4 with grade + 30ms audio fades baked in. | ||
|
|
||
| `-ss` before `-i` for fast accurate seeking. Scale to 1080p from 4K. | ||
| Portrait sources (height > width) are scaled by height to preserve orientation. | ||
|
|
||
| `vertical=True` converts any source orientation to a 1080x1920 (or 720x1280 | ||
| for draft) canvas. Two layouts: | ||
|
|
||
| - "blur_pad" (default): the full original frame is scaled to fit inside | ||
| the canvas untouched (no cropping — text overlays and full-frame shots | ||
| stay intact), with a blurred/cropped copy of the same frame filling the | ||
| letterbox bars top/bottom (landscape source) or sides (narrower-than-9:16 | ||
| source). Use for anything that is NOT a genuine side-by-side split-screen | ||
| (single-subject shots, graphics/cards, B-roll) — cropping those would | ||
| cut off content for no benefit. | ||
| - "split_stack": for a genuine left/right split-screen (e.g. two hosts). | ||
| Crops the left half and right half of the frame and stacks them full- | ||
| width, top and bottom — uses the full 1080 width for each speaker | ||
| instead of shrinking both into a blurred letterbox. Only use this for | ||
| segments that are actually split-screen for their whole duration; | ||
| applying it to single-shot footage will crop content arbitrarily. | ||
|
|
||
| `split_faces` refines the split_stack crops: a list of two normalized | ||
| (x, y) face centers in the FULL source frame, [[left_cx, left_cy], | ||
| [right_cx, right_cy]]. Each panel's crop window is centered | ||
| horizontally on its face and places the face at ~42% of the panel | ||
| height (rule of thirds) instead of a blind center crop. Read the face | ||
| positions off a frame with timeline_view / ffmpeg before setting them. | ||
| Static crop — correct for locked-off studio shots; it does not track | ||
| a face that walks across the frame. | ||
|
|
||
| Quality ladder: | ||
| - final (default): 1080p libx264 fast CRF 20 | ||
| - preview: 1080p libx264 medium CRF 22 (evaluable for QC) | ||
|
|
@@ -171,18 +212,65 @@ def extract_segment( | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| portrait = is_portrait_source(source) | ||
| if draft: | ||
| scale = "scale=-2:1280" if portrait else "scale=1280:-2" | ||
| long_edge = 1280 if draft else 1920 | ||
|
|
||
| tonemap = TONEMAP_CHAIN if is_hdr_source(source) else "" | ||
|
|
||
| if vertical: | ||
| w, h = (long_edge * 9) // 16, long_edge | ||
| pre = f"{tonemap}," if tonemap else "" | ||
| if layout == "split_stack": | ||
| half_h = h // 2 | ||
| src_w, src_h = get_source_dims(source) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The Prompt for AI agents |
||
| panel_w = src_w / 2 | ||
| aspect = w / half_h # 1080/960 = 1.125 | ||
| crop_w = min(panel_w, src_h * aspect) | ||
| crop_h = crop_w / aspect | ||
| faces = split_faces or [[0.25, 0.5], [0.75, 0.5]] | ||
|
|
||
| def _panel_crop(i: int) -> tuple[int, int, int, int]: | ||
| panel_x0 = 0.0 if i == 0 else src_w / 2 | ||
| fcx = faces[i][0] * src_w | ||
| fcy = faces[i][1] * src_h | ||
| x = max(panel_x0, min(fcx - crop_w / 2, panel_x0 + panel_w - crop_w)) | ||
| # Face at ~42% of the crop height (rule of thirds), not dead center | ||
| y = max(0.0, min(fcy - 0.42 * crop_h, src_h - crop_h)) | ||
| # Even coords for yuv420 chroma alignment | ||
| return (int(crop_w) // 2 * 2, int(crop_h) // 2 * 2, | ||
| int(x) // 2 * 2, int(y) // 2 * 2) | ||
|
|
||
| lw, lh, lx, ly = _panel_crop(0) | ||
| rw, rh, rx, ry = _panel_crop(1) | ||
| filter_complex = ( | ||
| f"[0:v]{pre}split=2[left_src][right_src];" | ||
| f"[left_src]crop={lw}:{lh}:{lx}:{ly},scale={w}:{half_h}[top];" | ||
| f"[right_src]crop={rw}:{rh}:{rx}:{ry},scale={w}:{half_h}[bottom];" | ||
| f"[top][bottom]vstack=inputs=2" | ||
| ) | ||
| else: | ||
| # Blur-pad: full frame fit inside the canvas (no crop) over a | ||
| # blurred, cropped-to-fill copy of the same frame as background. | ||
| filter_complex = ( | ||
| f"[0:v]{pre}split=2[bg_src][fg_src];" | ||
| f"[bg_src]scale={w}:{h}:force_original_aspect_ratio=increase," | ||
| f"crop={w}:{h},gblur=sigma=25[bg];" | ||
| f"[fg_src]scale={w}:{h}:force_original_aspect_ratio=decrease[fg];" | ||
| f"[bg][fg]overlay=(W-w)/2:(H-h)/2" | ||
| ) | ||
| if grade_filter: | ||
| filter_complex += f",{grade_filter}" | ||
| filter_complex += "[outv]" | ||
| else: | ||
| scale = "scale=-2:1920" if portrait else "scale=1920:-2" | ||
|
|
||
| vf_parts: list[str] = [] | ||
| if is_hdr_source(source): | ||
| vf_parts.append(TONEMAP_CHAIN) | ||
| vf_parts.append(scale) | ||
| if grade_filter: | ||
| vf_parts.append(grade_filter) | ||
| vf = ",".join(vf_parts) | ||
| if draft: | ||
| scale = "scale=-2:1280" if portrait else "scale=1280:-2" | ||
| vf_parts: list[str] = [] | ||
| if tonemap: | ||
| vf_parts.append(tonemap) | ||
| vf_parts.append(scale) | ||
| if grade_filter: | ||
| vf_parts.append(grade_filter) | ||
| vf = ",".join(vf_parts) | ||
|
|
||
| # 30ms audio fades at both edges (Rule 3) — prevent pops | ||
| fade_out_start = max(0.0, duration - 0.03) | ||
|
|
@@ -200,7 +288,12 @@ def extract_segment( | |
| "-ss", f"{seg_start:.3f}", | ||
| "-i", str(source), | ||
| "-t", f"{duration:.3f}", | ||
| "-vf", vf, | ||
| ] | ||
| if vertical: | ||
| cmd += ["-filter_complex", filter_complex, "-map", "[outv]", "-map", "0:a"] | ||
| else: | ||
| cmd += ["-vf", vf] | ||
| cmd += [ | ||
| "-af", af, | ||
| "-c:v", "libx264", "-preset", preset, "-crf", crf, | ||
| "-pix_fmt", "yuv420p", "-r", "24", | ||
|
|
@@ -216,6 +309,7 @@ def extract_all_segments( | |
| edit_dir: Path, | ||
| preview: bool, | ||
| draft: bool = False, | ||
| vertical: bool = False, | ||
| ) -> list[Path]: | ||
| """Extract every EDL range into edit_dir/clips_graded/seg_NN.mp4. | ||
| Returns the ordered list of segment paths. | ||
|
|
@@ -255,7 +349,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) | ||
| layout = r.get("layout", "blur_pad") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: New EDL fields Prompt for AI agents |
||
| extract_segment(src_path, start, duration, seg_filter, out_path, preview=preview, draft=draft, vertical=vertical, layout=layout, split_faces=r.get("split_faces")) | ||
| seg_paths.append(out_path) | ||
|
|
||
| return seg_paths | ||
|
|
@@ -537,7 +632,7 @@ def build_final_composite( | |
|
|
||
| # Subtitles LAST — Rule 1 | ||
| if has_subs: | ||
| subs_abs = str(subtitles_path.resolve()).replace(":", r"\:").replace("'", r"\'") | ||
| 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]" | ||
| ) | ||
|
|
@@ -573,6 +668,11 @@ def build_final_composite( | |
|
|
||
|
|
||
| def main() -> None: | ||
| # Windows consoles often default to cp1252, which can't encode the arrow | ||
| # characters used in progress prints (U+2192) — replace instead of crashing. | ||
| sys.stdout.reconfigure(errors="replace") | ||
| sys.stderr.reconfigure(errors="replace") | ||
|
|
||
| ap = argparse.ArgumentParser(description="Render a video from an EDL") | ||
| ap.add_argument("edl", type=Path, help="Path to edl.json") | ||
| ap.add_argument("-o", "--output", type=Path, required=True, help="Output video path") | ||
|
|
@@ -601,6 +701,13 @@ def main() -> None: | |
| action="store_true", | ||
| help="Skip audio loudness normalization. Default is on (-14 LUFS, -1 dBTP, LRA 11).", | ||
| ) | ||
| ap.add_argument( | ||
| "--vertical", | ||
| action="store_true", | ||
| help="Force 1080x1920 (720x1280 in --draft) output via blur-pad, regardless of " | ||
| "source orientation. Full frame is preserved (no crop) with a blurred " | ||
| "copy of the same frame filling the letterbox bars.", | ||
| ) | ||
| args = ap.parse_args() | ||
|
|
||
| edl_path = args.edl.resolve() | ||
|
|
@@ -613,7 +720,7 @@ def main() -> None: | |
|
|
||
| # 1. Extract per-segment (auto-grade per range if EDL grade is "auto") | ||
| segment_paths = extract_all_segments( | ||
| edl, edit_dir, preview=args.preview, draft=args.draft | ||
| edl, edit_dir, preview=args.preview, draft=args.draft, vertical=args.vertical | ||
| ) | ||
|
|
||
| # 2. Concat → base | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Permission
Bash(npm config get *)grants wildcard read access to all npm configuration values, which can include//registry.npmjs.org/:_authTokenand other sensitive auth/settings. No npm usage, package.json, or JavaScript toolchain exists in this project — this widens the attack surface for no functional reason.Prompt for AI agents