diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..932f15fc --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(ffprobe *)", + "PowerShell(Get-Process *)", + "Bash(npm config get *)", + "mcp__claude_ai_Google_Drive__search_files", + "mcp__claude_ai_Google_Drive__get_file_metadata" + ] + } +} diff --git a/.gitignore b/.gitignore index ea1791ad..a84413da 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ *.pem *.key +# Local agent config (user-specific, may contain tokens in permission rules) +.claude/settings.local.json +.claude/memory/ + # Python __pycache__/ *.py[cod] @@ -41,6 +45,9 @@ yarn.lock media/ *.log +# Local project outputs (raw downloads, renders) kept alongside the repo +videouse-projects/ + # Session artifacts if someone invokes the skill inside the project dir # (normally outputs go to /edit/ — keep these as a safety net) edit/ diff --git a/checkpoint-summary.md b/checkpoint-summary.md new file mode 100644 index 00000000..b2f3e32a --- /dev/null +++ b/checkpoint-summary.md @@ -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) +- 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 diff --git a/decisions.md b/decisions.md new file mode 100644 index 00000000..53a6d468 --- /dev/null +++ b/decisions.md @@ -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._ diff --git a/helpers/render.py b/helpers/render.py index 0d02cffa..f04fd548 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -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) + 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") + 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