{"slug": "heygen-biweekly-video-skill-for-claude-code-avatar-hosted-engineering-updates", "title": "HeyGen Biweekly Video skill for Claude Code — avatar-hosted engineering updates from GitHub activity. Drop into ~/.claude/skills/ and invoke with /heygen-biweekly-video.", "summary": "HeyGen has released a repeatable pipeline for producing biweekly team-update videos featuring an AI avatar narrator over real product UI and motion graphics, outputting 1920×1080/60fps clips of approximately 90 seconds. The skill, invoked via `/heygen-biweekly-video`, automates the entire workflow—from pulling GitHub metrics and writing a 250-word script to capturing live product screenshots, syncing captions to the avatar's voice, and rendering with music ducked under narration. The pipeline uses HeyGen CLI for avatar generation, HyperFrames for motion graphics, and agent-browser for capturing real product interfaces rather than mockups.", "body_md": "| name | heygen-biweekly-video |\n|---|---|\n| description | Produce a launch-grade biweekly team-update video — avatar-hosted (HeyGen CLI), built in HyperFrames, with real captured product UI, real preview videos, kinetic captions, smooth camera moves, and music ducked under the voice. Use when: biweekly / sprint recap video, avatar-narrated dev update, changelog-as-video. |\n\nA repeatable pipeline for an After-Effects-quality team update: a HeyGen avatar narrates\nover **real captured product UI** + motion graphics, every beat synced to the avatar's voice.\nOutput: 1920×1080 / 60fps / ~90s.\n\n`heygen`\n\nCLI authed (`heygen auth status`\n\n) — get it at[https://github.com/heygen-com/skills](https://github.com/heygen-com/skills)`hyperframes`\n\nCLI (`npx hyperframes`\n\n) —[https://github.com/heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)`bun`\n\n,`ffmpeg`\n\n/`ffprobe`\n\n,`agent-browser`\n\n,`gh`\n\n,`jq`\n\n**Everything REAL — no mockups.** Capture actual product UI via website-to-html; use real preview videos; use real numbers from GitHub.**The avatar's voice is the single narration spine.** One continuous take; transcribe for word-level timings; sync every act and caption to those cues.**Verify by rendering frames**— extract a frame (`ffmpeg -ss <t> ... -vframes 1`\n\n) and*look*before claiming any scene works.\n\n| # | Phase | What to do |\n|---|---|---|\n| 0 | Reference |\nStudy `heygen-com/hyperframes-launches` — the quality bar. Pick the closest launch, reuse its music/SFX. |\n| 1 | Count |\n`gh search prs --author=@me --created=<start>..<end>` → cluster into 3 hero themes |\n| 2 | Numbers |\nPull public metrics (GitHub stars, PRs, releases, contributors, catalog size) |\n| 3 | Avatar |\nWrite ~250-word script → `heygen video create -d '{\"type\":\"avatar\",\"avatar_id\":\"<ID>\",\"script\":\"...\",\"output_format\":\"webm\"}'` → transparent webm → transcribe for cues |\n| 4 | Capture UI |\nRun your app locally → capture via agent-browser + CDP MHTML → convert to standalone HTML |\n| 5 | Build acts |\ncold-open → intro → hero sections → stats → CTA/outro |\n| 6 | Wire master |\nAvatar full→PiP→full, music + SFX + voice |\n| 7 | Render + master |\n`hyperframes render --fps 60 --quality high` → duck music under voice → deliver |\n\n```\nheygen auth status\nheygen avatar looks get <LOOK_ID>    # confirm look + engines; group_id is NOT a 2nd avatar\n\n# write script to script.txt (~250 words ≈ 90s at 150wpm), then:\njq -Rs '{type:\"avatar\",avatar_id:\"<ID>\",script:.,output_format:\"webm\",aspect_ratio:\"9:16\",resolution:\"1080p\"}' \\\n  script.txt > req.json\nheygen video create -d req.json      # → video_id; webm = transparent (alpha_mode=1)\n\n# poll until done\nVID=<video_id>\nfor i in $(seq 1 90); do\n  s=$(heygen video get $VID | jq -r .data.status)\n  [ \"$s\" = completed ] && break; sleep 20\ndone\n\nheygen video download $VID --output-path assets/host.webm --force\nffmpeg -y -i assets/host.webm -vn -c:a aac -b:a 192k -ar 48000 assets/host-voice.m4a\nnpx hyperframes transcribe assets/host.webm --model base.en    # → word-level transcript.json\n```\n\nFix Whisper mishears (product names like \"HeyGen\", \"HyperFrames\") before generating captions.\n\n```\n# open your app\nagent-browser set viewport 1920 1080\nagent-browser open \"http://localhost:5190/\"\nagent-browser wait --load networkidle\n\n# drive to the exact state (click tabs, seek timeline, select elements)\nagent-browser click @eN\nagent-browser screenshot /tmp/state.png   # reference\n\n# capture MHTML via CDP\nagent-browser get cdp-url | grep -o 'ws://[^ ]*' > /tmp/cdp.txt\n# grab-mhtml.mjs (bundled below): attaches to the page target, runs Page.captureSnapshot\nbun grab-mhtml.mjs \"$(cat /tmp/cdp.txt)\" /tmp/page.mhtml\n\n# convert to standalone HTML\nbun mhtml-to-html.mjs /tmp/page.mhtml captures/page.html\nperl -i -pe 's/[\\w.-]*\\@mhtml\\.blink/about:blank/g' captures/page.html\n\n# wrap into a HyperFrames sub-comp backdrop\nbun build-studio-bg.mjs captures/page.html compositions/page-bg.html page-bg 15\n```\n\n**Light-themed captures (Next.js etc.) MUST be iframe-isolated** — their global CSS leaks and turns the whole video white. Use `<iframe src=\"../captures/page.html\">`\n\ninstead of inlining.\n\nA flat `data-volume`\n\non the music track is NOT enough — the track's body is far louder than its intro. Flatten dynamics first, then duck:\n\n```\n# master-audio.sh (bundled below):\nbash master-audio.sh renders/final.mp4 assets/music.mp3 assets/host-voice.m4a <voiceStartSec> \\\n  ~/Downloads/output.mp4 0.03 0.22\n# dynaudnorm flattens the music → envelope ducks to 3% under voice → loudnorm -14 LUFS → mux over video\n```\n\n**Light captures leak CSS globally**→ iframe-isolate them** Use**, never`gsap.fromTo()`\n\nin sub-compositions`gsap.from()`\n\n—`immediateRender`\n\nbreaks**Never CSS**— use`transform`\n\n+ GSAP transform on same element`xPercent`\n\n/`yPercent`\n\nor flex centeringor the renderer reports FROZEN/SILENT`<video>`\n\n/`<audio>`\n\nneed an`id`\n\n**One stat per scene** for data acts — three at once overlap**Verify by extracting frames**— collisions are invisible until you look** Avatar look vs group ID**— they can look like two avatars but be one\n\n``` js\n#!/usr/bin/env bun\nimport { writeFileSync } from \"node:fs\";\nconst [url, outPath] = process.argv.slice(2);\nconst ws = new WebSocket(url);\nconst send = (o) => ws.send(JSON.stringify(o));\nws.addEventListener(\"open\", () => send({ id: 1, method: \"Target.getTargets\" }));\nws.addEventListener(\"message\", (ev) => {\n  const d = JSON.parse(ev.data);\n  if (d.id === 1) {\n    const t = d.result.targetInfos.find((t) => t.type === \"page\" && t.url.includes(\"localhost\"));\n    if (!t) { console.log(\"no page target\"); ws.close(); return; }\n    send({ id: 2, method: \"Target.attachToTarget\", params: { targetId: t.targetId, flatten: true } });\n  }\n  if (d.id === 2) send({ id: 3, sessionId: d.result.sessionId, method: \"Page.captureSnapshot\", params: { format: \"mhtml\" } });\n  if (d.id === 3) {\n    if (d.result?.data) { writeFileSync(outPath, d.result.data); console.log(\"MHTML OK:\", d.result.data.length, \"bytes\"); }\n    ws.close();\n  }\n});\nsetTimeout(() => process.exit(0), 20000);\njs\n#!/usr/bin/env bun\nimport { readFileSync, writeFileSync } from \"node:fs\";\nconst [inPath, outPath] = process.argv.slice(2);\nconst raw = readFileSync(inPath, \"latin1\");\nconst boundaryMatch = raw.match(/boundary=\"([^\"]+)\"/);\nconst boundary = \"--\" + boundaryMatch[1];\nconst chunks = raw.split(boundary).filter((c) => c.trim() && !c.trim().startsWith(\"--\"));\nfunction parsePart(chunk) {\n  const idx = chunk.search(/\\r?\\n\\r?\\n/);\n  if (idx === -1) return null;\n  const headerBlock = chunk.slice(0, idx);\n  const sepLen = chunk.slice(idx).match(/^\\r?\\n\\r?\\n/)[0].length;\n  let body = chunk.slice(idx + sepLen);\n  const headers = {};\n  for (const line of headerBlock.split(/\\r?\\n/)) {\n    const m = line.match(/^([\\w-]+):\\s*(.*)$/);\n    if (m) headers[m[1].toLowerCase()] = m[2].trim();\n  }\n  return { headers, body };\n}\nfunction decodeQuotedPrintable(str) {\n  const noSoft = str.replace(/=\\r?\\n/g, \"\");\n  const bytes = [];\n  for (let i = 0; i < noSoft.length; i++) {\n    if (noSoft[i] === \"=\" && i + 2 < noSoft.length && /^[0-9A-Fa-f]{2}$/.test(noSoft.substr(i + 1, 2))) {\n      bytes.push(parseInt(noSoft.substr(i + 1, 2), 16)); i += 2;\n    } else bytes.push(noSoft.charCodeAt(i) & 0xff);\n  }\n  return Buffer.from(bytes);\n}\nfunction decodeBody(headers, body) {\n  const enc = (headers[\"content-transfer-encoding\"] || \"\").toLowerCase();\n  if (enc === \"base64\") return Buffer.from(body.replace(/\\s+/g, \"\"), \"base64\");\n  if (enc === \"quoted-printable\") return decodeQuotedPrintable(body);\n  return Buffer.from(body, \"latin1\");\n}\nlet htmlPart = null;\nconst resourceMap = new Map();\nfor (const chunk of chunks) {\n  const part = parsePart(chunk);\n  if (!part) continue;\n  const ctype = (part.headers[\"content-type\"] || \"\").split(\";\")[0].trim();\n  const loc = part.headers[\"content-location\"];\n  const buf = decodeBody(part.headers, part.body);\n  if (ctype === \"text/html\" && !htmlPart) { htmlPart = buf.toString(\"utf8\"); continue; }\n  if (loc) resourceMap.set(loc, `data:${ctype};base64,${buf.toString(\"base64\")}`);\n}\nlet html = htmlPart;\nfor (const loc of [...resourceMap.keys()].sort((a, b) => b.length - a.length)) html = html.split(loc).join(resourceMap.get(loc));\nhtml = html.replace(/<script[\\s\\S]*?<\\/script>/gi, \"\");\nhtml = html.replace(/<link[^>]*rel=[\"']?(?:preload|prefetch|modulepreload)[\"']?[^>]*>/gi, \"\");\nwriteFileSync(outPath, html, \"utf8\");\nconsole.log(`wrote ${outPath} (${(html.length / 1024).toFixed(0)} KB) — inlined ${resourceMap.size} resources`);\nbash\n#!/usr/bin/env bash\nset -euo pipefail\nVIDEO=$1; MUSIC=$2; VOICE=$3; VSTART=$4; OUT=$5; DUCK=${6:-0.05}; INTRO=${7:-0.22}\nDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 \"$VIDEO\")\nDELAY=$(awk \"BEGIN{printf \\\"%d\\\", $VSTART*1000}\")\nTMP=$(mktemp -d)/master.m4a\nffmpeg -y -v error -i \"$MUSIC\" -i \"$VOICE\" -filter_complex \"\\\n[0:a]atrim=0:${DUR},dynaudnorm=f=200:g=15,volume='if(lt(t,${VSTART}),${INTRO},${DUCK})':eval=frame[bed]; \\\n[1:a]adelay=${DELAY}|${DELAY},apad=whole_dur=${DUR},volume=1.0[vox]; \\\n[bed][vox]amix=inputs=2:duration=longest:normalize=0[mix]; \\\n[mix]loudnorm=I=-14:TP=-1.5:LRA=11[out]\" \\\n  -map \"[out]\" -t \"$DUR\" -c:a aac -b:a 256k \"$TMP\"\nffmpeg -y -v error -i \"$VIDEO\" -i \"$TMP\" -map 0:v -map 1:a -c:v copy -c:a aac -movflags +faststart \"$OUT\"\necho \"wrote $OUT  (music ducked to ${DUCK} under voice, -14 LUFS)\"\n```\n\nQuick way (installs the HeyGen + HyperFrames CLI skills that this workflow uses):\n\n```\nnpx skills add heygen-com/skills\nnpx skills add heygen-com/hyperframes\n```\n\nThen for this skill:\n\nSave this file as `~/.claude/skills/heygen-biweekly-video/SKILL.md`\n\n, then invoke with `/heygen-biweekly-video`\n\nin Claude Code.", "url": "https://wpnews.pro/news/heygen-biweekly-video-skill-for-claude-code-avatar-hosted-engineering-updates", "canonical_source": "https://gist.github.com/miguel-heygen/9337897578a915f4645d39d9d0b20703", "published_at": "2026-05-30 00:50:57+00:00", "updated_at": "2026-05-30 18:43:23.773420+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "generative-ai"], "entities": ["HeyGen", "Claude Code", "GitHub", "HyperFrames"], "alternates": {"html": "https://wpnews.pro/news/heygen-biweekly-video-skill-for-claude-code-avatar-hosted-engineering-updates", "markdown": "https://wpnews.pro/news/heygen-biweekly-video-skill-for-claude-code-avatar-hosted-engineering-updates.md", "text": "https://wpnews.pro/news/heygen-biweekly-video-skill-for-claude-code-avatar-hosted-engineering-updates.txt", "jsonld": "https://wpnews.pro/news/heygen-biweekly-video-skill-for-claude-code-avatar-hosted-engineering-updates.jsonld"}}