Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/settings.json
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 *)",

@cubic-dev-ai cubic-dev-ai Bot Jul 8, 2026

Copy link
Copy Markdown

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/:_authToken and 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
Check if this issue is valid — if so, understand the root cause and fix it. At .claude/settings.json, line 6:

<comment>Permission `Bash(npm config get *)` grants wildcard read access to all npm configuration values, which can include `//registry.npmjs.org/:_authToken` and 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.</comment>

<file context>
@@ -0,0 +1,11 @@
+    "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"
</file context>
Fix with cubic

"mcp__claude_ai_Google_Drive__search_files",

@cubic-dev-ai cubic-dev-ai Bot Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Google Drive MCP tool permissions (search_files, get_file_metadata) are granted with no code, skill, or documentation reference to Google Drive integration. Remove unused permissions to keep the approval surface clean and avoid prompting users for scope they don't need.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .claude/settings.json, line 7:

<comment>Google Drive MCP tool permissions (`search_files`, `get_file_metadata`) are granted with no code, skill, or documentation reference to Google Drive integration. Remove unused permissions to keep the approval surface clean and avoid prompting users for scope they don't need.</comment>

<file context>
@@ -0,0 +1,11 @@
+      "Bash(ffprobe *)",
+      "PowerShell(Get-Process *)",
+      "Bash(npm config get *)",
+      "mcp__claude_ai_Google_Drive__search_files",
+      "mcp__claude_ai_Google_Drive__get_file_metadata"
+    ]
</file context>
Fix with cubic

"mcp__claude_ai_Google_Drive__get_file_metadata"
]
}
}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 <videos_dir>/edit/ — keep these as a safety net)
edit/
Expand Down
12 changes: 12 additions & 0 deletions checkpoint-summary.md
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)

@cubic-dev-ai cubic-dev-ai Bot Jul 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Konteks section references absolute Windows paths (C:\claudeall\BRIEF.md, C:\claudeall\checkpoint-summary.md) that are machine-specific. Since this file is tracked in the repo, these paths will be meaningless to anyone else and won't resolve on macOS/Linux systems. Consider either (a) removing the hardcoded paths and describing the reference context generically, (b) using a relative workspace convention, or (c) keeping such personal paths in a gitignored local file (.gitignore already excludes project.md for similar session-memory files).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At checkpoint-summary.md, line 8:

<comment>The Konteks section references absolute Windows paths (`C:\claudeall\BRIEF.md`, `C:\claudeall\checkpoint-summary.md`) that are machine-specific. Since this file is tracked in the repo, these paths will be meaningless to anyone else and won't resolve on macOS/Linux systems. Consider either (a) removing the hardcoded paths and describing the reference context generically, (b) using a relative workspace convention, or (c) keeping such personal paths in a gitignored local file (`.gitignore` already excludes `project.md` for similar session-memory files).</comment>

<file context>
@@ -0,0 +1,12 @@
+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)
+
</file context>
Fix with cubic

- 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
14 changes: 14 additions & 0 deletions decisions.md
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._
135 changes: 121 additions & 14 deletions helpers/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The split_stack crop calculations use get_source_dims() (ffprobe coded width/height), but ffmpeg's filter graph auto-rotates frames by default when display-matrix rotation metadata is present (common in phone footage). This means [0:v] may see transposed dimensions while the crop math assumes coded dimensions, producing invalid crop coordinates that target the wrong region or exceed the actual frame bounds. For example, a 1920×1080 clip with rotate=90 becomes 1080×1920 in the graph, yet the right-panel crop is computed starting at x=960 with width≈960 — extending past the 1080-wide rotated frame. To keep the filter graph consistent with ffprobe, pass -noautorotate before -i in the ffmpeg command.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/render.py, line 224:

<comment>The `split_stack` crop calculations use `get_source_dims()` (ffprobe coded width/height), but ffmpeg's filter graph auto-rotates frames by default when display-matrix rotation metadata is present (common in phone footage). This means `[0:v]` may see transposed dimensions while the crop math assumes coded dimensions, producing invalid crop coordinates that target the wrong region or exceed the actual frame bounds. For example, a 1920×1080 clip with rotate=90 becomes 1080×1920 in the graph, yet the right-panel crop is computed starting at x=960 with width≈960 — extending past the 1080-wide rotated frame. To keep the filter graph consistent with ffprobe, pass `-noautorotate` before `-i` in the ffmpeg command.</comment>

<file context>
@@ -171,18 +212,65 @@ def extract_segment(
+        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
</file context>
Fix with cubic

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)
Expand All @@ -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",
Expand All @@ -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.
Expand Down Expand Up @@ -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")

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: New EDL fields layout and split_faces are read from the JSON range objects without validation. A typo in layout (e.g. "split-stack") silently falls back to blur_pad instead of the intended layout, producing a wrong render with no warning. Malformed split_faces values crash the render with an IndexError or TypeError inside _panel_crop. Consider validating layout against the known set of layouts and checking that split_faces is a list of two [float, float] pairs before using it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/render.py, line 352:

<comment>New EDL fields `layout` and `split_faces` are read from the JSON range objects without validation. A typo in `layout` (e.g. `"split-stack"`) silently falls back to `blur_pad` instead of the intended layout, producing a wrong render with no warning. Malformed `split_faces` values crash the render with an `IndexError` or `TypeError` inside `_panel_crop`. Consider validating `layout` against the known set of layouts and checking that `split_faces` is a list of two `[float, float]` pairs before using it.</comment>

<file context>
@@ -255,7 +349,8 @@ def extract_all_segments(
         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)
</file context>
Fix with cubic

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
Expand Down Expand Up @@ -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]"
)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down