| name | longform-video-clone-edit |
| description | End-to-end longform video cloning pipeline. Downloads YouTube video, transcribes, generates HeyGen avatar chunks, detects PIP vs fullscreen vs noface segments, precisely locates webcam bubbles, composites avatar overlay with lip-synced audio. Handles screen recordings with PIP webcam bubbles and fullscreen talking head. |
| metadata | {"openclaw":{"emoji":"🎬","requires":{"envVars":["HEYGEN_API_KEY"],"anyBins":["ffmpeg","ffprobe","yt-dlp","python3"],"pip":["opencv-python-headless","requests"]}}} |
Longform Video Clone Pipeline
Clone longform YouTube videos (10-60 min) by replacing the creator's face with a HeyGen avatar. Handles mixed content: fullscreen talking head, screen recordings with PIP webcam bubbles, and no-face segments.
Key Parameters (per-project)
- HeyGen Avatar ID: Set per project
- HeyGen Voice ID: Set per project
- Working dir:
~/.openclaw/workspace/face-swap-clone/longform-v2/ (or per-project)
Working Directory Structure
project/
├── source/
│ ├── video.mp4 # Downloaded source video
│ ├── transcript.json # Whisper word-level transcript
│ ├── chunks.json # Text chunks for HeyGen (≤4000 chars each)
│ ├── heygen_jobs.json # HeyGen job IDs
│ ├── classification.json # Segment classification (pip/fullscreen/noface)
│ └── bubble_bounds.json # Detected webcam bubble coordinates
├── avatar/
│ ├── chunk_0.mp4 ... chunk_N.mp4 # Downloaded HeyGen outputs
│ ├── avatar_stitched.mp4 # Concatenated avatar chunks
│ └── avatar_sped.mp4 # Speed-matched to source duration
├── segments/ # Cut video segments (no audio)
├── frames/ # Extracted frames for analysis
└── renders/ # Final composited outputs
Pipeline Overview
- Download source video
- Transcribe with Whisper (word-level timestamps)
- Chunk transcript → submit to HeyGen (≤4000 chars per chunk)
- Poll + download HeyGen chunks
- Stitch chunks → speed-match to source duration
- Face detection: classify every second as fullscreen / PIP / noface
- Detect exact webcam bubble bounds from source pixels
- Composite: overlay avatar on each segment type
- Mux avatar audio onto final video (NOT source audio)
- Compress for delivery
Step 1: Download Source Video
mkdir -p project/{source,avatar,segments,frames,renders}
yt-dlp -o "source/video.mp4" "YOUTUBE_URL"
ffprobe -v error -show_entries format=duration,stream=width,height,r_frame_rate -of json source/video.mp4
Step 2: Transcribe
Use the whisper-transcribe skill or OpenAI Whisper directly:
whisper source/video.mp4 --model base --output_format json --word_timestamps True
Output: source/transcript.json
Step 3: Chunk Transcript for HeyGen
CRITICAL: HeyGen has a 4000 character limit per input. Split transcript into chunks.
import json
transcript = json.load(open("source/transcript.json"))
full_text = transcript["text"]
chunks = []
current = ""
for sentence in full_text.replace(". ", ".\n").split("\n"):
if len(current) + len(sentence) + 1 > 3800:
chunks.append(current.strip())
current = sentence
else:
current += " " + sentence
if current.strip():
chunks.append(current.strip())
json.dump(chunks, open("source/chunks.json", "w"))
print(f"{len(chunks)} chunks, max {max(len(c) for c in chunks)} chars")
Step 4: Submit HeyGen Jobs
import json, os, time, requests
API_KEY = os.environ["HEYGEN_API_KEY"]
chunks = json.load(open("source/chunks.json"))
AVATAR_ID = "YOUR_AVATAR_ID"
VOICE_ID = "YOUR_VOICE_ID"
jobs = []
for i, text in enumerate(chunks):
resp = requests.post("https://api.heygen.com/v2/video/generate",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={
"video_inputs": [{
"character": {"type": "avatar", "avatar_id": AVATAR_ID, "avatar_style": "normal"},
"voice": {"type": "text", "voice_id": VOICE_ID, "input_text": text}
}],
"dimension": {"width": 1920, "height": 1080}
})
vid = resp.json()["data"]["video_id"]
print(f"Chunk {i}: {vid}")
jobs.append(vid)
time.sleep(2)
json.dump(jobs, open("source/heygen_jobs.json", "w"))
Voice type: Use "type": "text" (NOT "type": "id" — that's deprecated).
Step 5: Poll + Download Chunks
Run as background poller (fire-and-forget):
import json, os, time, requests
API_KEY = os.environ["HEYGEN_API_KEY"]
jobs = json.load(open("source/heygen_jobs.json"))
os.makedirs("avatar", exist_ok=True)
while True:
done = 0
for i, vid in enumerate(jobs):
outfile = f"avatar/chunk_{i}.mp4"
if os.path.exists(outfile):
done += 1
continue
resp = requests.get(f"https://api.heygen.com/v1/video_status.get?video_id={vid}",
headers={"X-Api-Key": API_KEY}).json()
status = resp.get("data", {}).get("status", "unknown")
if status == "completed":
url = resp["data"]["video_url"]
r = requests.get(url)
with open(outfile, "wb") as f:
f.write(r.content)
done += 1
elif status == "failed":
error = resp.get("data", {}).get("error", {})
print(f"CHUNK {i} FAILED: {error}")
print(f"{time.strftime('%H:%M:%S')}: {done}/{len(jobs)}")
if done >= len(jobs):
break
time.sleep(30)
Watchdog: HeyGen pollers can die silently. Always verify completion. Check for "Insufficient credit" errors.
Step 6: Stitch + Speed-Match
for i in $(seq 0 $((N-1))); do echo "file 'chunk_${i}.mp4'"; done > avatar/concat_list.txt
cd avatar && ffmpeg -y -f concat -safe 0 -i concat_list.txt -c copy avatar_stitched.mp4
SOURCE_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 ../source/video.mp4)
AVATAR_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 avatar_stitched.mp4)
FACTOR=$(python3 -c "print(f'{$AVATAR_DUR / $SOURCE_DUR:.6f}')")
ffmpeg -y -i avatar_stitched.mp4 \
-filter_complex "[0:v]setpts=PTS/${FACTOR}[v];[0:a]atempo=${FACTOR}[a]" \
-map "[v]" -map "[a]" -r 30 -c:v libx264 -preset fast -crf 18 -c:a aac -b:a 128k \
avatar_sped.mp4
⚠️ CRITICAL: Get the speed direction right!
avatar < source → factor < 1 → slows down (stretches)
avatar > source → factor > 1 → speeds up (compresses)
atempo only accepts 0.5-2.0. For extreme values, chain: atempo=0.5,atempo=0.8
Step 7: Face Detection + Segment Classification
Classify every second of the video as one of:
- fullscreen: Creator's face is the main content (talking head)
- pip: Screen recording with a webcam bubble overlay containing the creator's face
- noface: No face detected (B-roll, slides without webcam)
import cv2
import json
cap = cv2.VideoCapture("source/video.mp4")
fps = int(cap.get(cv2.CAP_PROP_FPS))
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cc = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
classifications = []
for sec in range(0, total_frames // fps):
frame_num = sec * fps
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
ret, frame = cap.read()
if not ret:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = cc.detectMultiScale(gray, 1.1, 5, minSize=(50, 50))
if len(faces) == 0:
classifications.append((frame_num, "noface", 0, 0, 0))
else:
largest = max(faces, key=lambda f: f[2] * f[3])
x, y, w, h = largest
cx, cy = x + w // 2, y + h // 2
if cy > height * 0.6 and w < width * 0.3:
classifications.append((frame_num, "pip", cx, cy, w))
else:
classifications.append((frame_num, "fullscreen", cx, cy, w))
cap.release()
Merge into Ranges
ranges = []
if classifications:
curr_type = classifications[0][1]
start_frame = classifications[0][0]
face_positions = [(classifications[0][2], classifications[0][3])]
for frame_num, seg_type, cx, cy, w in classifications[1:]:
if seg_type != curr_type:
avg_cx = int(sum(p[0] for p in face_positions) / len(face_positions))
avg_cy = int(sum(p[1] for p in face_positions) / len(face_positions))
ranges.append({
"start": start_frame,
"end": frame_num,
"type": curr_type,
"face": [avg_cx, avg_cy] if curr_type != "noface" else None
})
curr_type = seg_type
start_frame = frame_num
face_positions = [(cx, cy)]
else:
face_positions.append((cx, cy))
avg_cx = int(sum(p[0] for p in face_positions) / len(face_positions))
avg_cy = int(sum(p[1] for p in face_positions) / len(face_positions))
ranges.append({
"start": start_frame,
"end": total_frames,
"type": curr_type,
"face": [avg_cx, avg_cy] if curr_type != "noface" else None
})
merged = [ranges[0]]
for r in ranges[1:]:
prev = merged[-1]
dur_s = (r["end"] - r["start"]) / fps
if dur_s < 8 and prev["type"] != "noface" and len(merged) > 1:
merged[-1]["end"] = r["end"]
else:
merged.append(r)
json.dump(merged, open("source/classification.json", "w"), indent=2)
Step 8: Detect Webcam Bubble Bounds (CRITICAL FOR PIP)
For PIP segments, find the exact pixel boundaries of the webcam bubble in the source video. Do NOT estimate — detect from actual frames.
import cv2
import numpy as np
def detect_bubble_bounds(source_path, face_cx, face_cy, fps):
"""Detect exact webcam bubble boundaries around a known face position."""
cap = cv2.VideoCapture(source_path)
ret, frame = cap.read()
h, w = frame.shape[:2]
left_edge = face_cx
for x in range(face_cx, max(face_cx - 500, 0), -1):
if frame[face_cy, x, :].mean() > 230 and frame[face_cy, min(x+5, w-1), :].mean() < 200:
left_edge = x + 1
break
right_edge = face_cx
for x in range(face_cx, min(face_cx + 500, w)):
if frame[face_cy, x, :].mean() > 230:
right_edge = x - 1
break
top_edge = face_cy
for y in range(face_cy, max(face_cy - 400, 0), -1):
curr = frame[y, face_cx, :].mean()
prev = frame[y-1, face_cx, :].mean()
if curr < 200 and prev > 220:
top_edge = y
break
bottom_edge = min(face_cy + 200, h)
for y in range(h - 1, face_cy, -1):
if frame[y, face_cx, :].mean() > 230:
bottom_edge = y
break
cap.release()
return {
"x": left_edge,
"y": top_edge,
"w": right_edge - left_edge,
"h": bottom_edge - top_edge
}
Key insight: Webcam bubbles in screen recordings are typically:
- Bottom-left or bottom-right corner
- Rounded rectangles with subtle shadow/border
- Consistent size throughout the video (~389x292 in 1080p)
- Face position (left vs right) may change during the video
Sample multiple PIP frames to verify bubble position consistency and detect left/right switches.
Step 9: Detect Avatar Face Position
import cv2
cc = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
cap = cv2.VideoCapture("avatar/avatar_sped.mp4")
positions = []
for t in [30, 60, 120, 180]:
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
ret, frame = cap.read()
if not ret: continue
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = cc.detectMultiScale(gray, 1.1, 5, minSize=(100, 100))
if len(faces) > 0:
largest = max(faces, key=lambda f: f[2] * f[3])
x, y, w, h = largest
positions.append((x + w//2, y + h//2, w, h))
avg_cx = int(sum(p[0] for p in positions) / len(positions))
avg_cy = int(sum(p[1] for p in positions) / len(positions))
avg_w = int(sum(p[2] for p in positions) / len(positions))
avg_h = int(sum(p[3] for p in positions) / len(positions))
CROP_W = int(avg_w * 2.0)
CROP_H = int(CROP_W * bubble_h / bubble_w)
CROP_X = avg_cx - CROP_W // 2
CROP_Y = avg_cy - CROP_H // 2
Step 10: Build Composite
⚠️ CRITICAL: All segments rendered WITHOUT audio (-an). Avatar audio muxed at the very end. This ensures lip sync is perfect.
#!/bin/bash
set -e
SOURCE="source/video.mp4"
AVATAR="avatar/avatar_sped.mp4"
for each range in classification:
case $type in
"fullscreen")
ffmpeg -y -ss $start -t $dur -i "$AVATAR" \
-c:v libx264 -preset fast -crf 18 -an -f mpegts \
segments/seg_${i}.ts
;;
"pip")
ffmpeg -y -ss $start -t $dur -i "$SOURCE" -ss $start -t $dur -i "$AVATAR" \
-filter_complex \
"[1:v]crop=${CROP_W}:${CROP_H}:${CROP_X}:${CROP_Y},scale=${BUB_W}:${BUB_H}[pip];
[0:v][pip]overlay=${BUB_X}:${BUB_Y}[out]" \
-map "[out]" -c:v libx264 -preset fast -crf 18 -an -f mpegts \
segments/seg_${i}.ts
;;
"noface")
ffmpeg -y -ss $start -t $dur -i "$SOURCE" \
-c:v libx264 -preset fast -crf 18 -an -f mpegts \
segments/seg_${i}.ts
;;
esac
done
ffmpeg -y -f concat -safe 0 -i concat_list.txt -c copy renders/nosound.mp4
ffmpeg -y -i renders/nosound.mp4 -i avatar/avatar_sped.mp4 \
-map 0:v -map 1:a -c:v copy -c:a aac -shortest \
renders/final_clone.mp4
Why Avatar Audio?
The avatar's lip movements are synced to the avatar's generated speech. Using source audio would create a mismatch because HeyGen generates speech at different pacing than the original. Speed-matching gets the total duration right, but micro-timing still differs. Avatar audio = perfect lip sync.
Step 11: Compress for Delivery
ffmpeg -y -i renders/final_clone.mp4 -vf "scale=360:-2" \
-c:v libx264 -crf 40 -preset fast -c:a aac -b:a 32k \
renders/telegram_preview.mp4
ffmpeg -y -i renders/final_clone.mp4 -vf "scale=640:-2" \
-c:v libx264 -crf 28 -preset fast -c:a aac -b:a 64k \
renders/preview_640p.mp4
Critical Lessons Learned
Speed-Match Direction
avatar_duration / source_duration = factor
- Factor < 1 → avatar is shorter → slow down (stretch)
- Factor > 1 → avatar is longer → speed up (compress)
- Got this wrong = completely wrong duration. Verify output duration after!
ffmpeg Concat Paths
- Paths in concat list files are relative to the list file, NOT cwd
- Run ffmpeg from the same directory as the list file, or use absolute paths
- Use
-f concat -safe 0 (not pipe concat — causes moov atom issues)
HeyGen Gotchas
- 4000 char limit per input — must chunk
- Voice type:
"type": "text" (not "type": "id")
- Pollers die silently — always verify with watchdog/cron
- Check for "Insufficient credit" errors on failures
- Typical processing: 10-20 min per chunk
PIP Detection
- Position-based: face Y > 60% of frame height + face width < 30% of frame width = PIP
- Dense 1-second scanning (every fps frames) catches transitions
- Merge small gaps (<8s) between same-type ranges to reduce fragmentation
- Webcam bubble position (left/right) can change mid-video — detect per-range
Webcam Bubble Detection
- Do NOT estimate bubble size — detect exact pixel boundaries
- Scan outward from known face center for brightness transitions
- Typical bubble: ~389x292 in 1080p, bottom corner, rounded rectangle
- Bubble extends to frame edge on the bottom and side
Audio
- Always use avatar audio, never source audio for the final render
- Render all video segments with
-an (no audio)
- Mux avatar audio as final step with
-map 0:v -map 1:a
- This is THE key to lip sync
Fullscreen Segments
- Use raw avatar frame — do NOT crop and rescale to 1920x1080
- Cropping 800x550 and scaling to 1920x1080 causes horizontal warp distortion
- The avatar is already rendered at the correct resolution
Fire-and-Forget for Long Encodes
- Long ffmpeg encodes (30+ min video): use
nohup bash script.sh &
- Monitor with cron jobs polling for file existence / completion markers
- Never block the main agent session on a 15-min encode