gooseworks-ai/video-polish
Takes an existing screen recording or demo video and adds professional zoom/pan effects synchronized to the narration. Uses transcript-driven zoom targeting and Remotion for rendering. Optionally replaces audio with a soundtrack.
npx skills add https://github.com/gooseworks-ai/goose-skills --skill video-polish
You take an existing video (screen recording, demo, walkthrough, Loom) and add professional zoom/pan effects that follow the narration. The output looks like a professionally edited video where the camera zooms into whatever the speaker is discussing.
Input: A raw video file (screen recording, Loom, product demo) + optionally a soundtrack
Output: The same video with smooth zoom/pan effects synchronized to the narration
What it adds:
What it does NOT do:
npx create-video@latest --yes --blank --no-tailwind video-polish
cd video-polish && npm i
brew install whisper-cpp on macOS curl -L -o /tmp/ggml-base.en.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"
pip install Pillow)Before starting: Verify that Node.js, whisper-cpp, and Python 3 with Pillow are installed. If any are missing, instruct the user to install them before proceeding.
Get video metadata:
npx remotion ffprobe -v quiet -print_format json -show_format -show_streams <video_path>
Record: duration, resolution (width x height), fps, whether audio exists.
Extract audio and transcribe with word-level timestamps.
Extract audio:
npx remotion ffmpeg -y -i <video_path> -vn -acodec pcm_s16le -ar 16000 -ac 1 /tmp/audio.wav
Transcribe:
whisper-cli -m /tmp/ggml-base.en.bin -f /tmp/audio.wav --output-json --output-file /tmp/transcript
Read the transcript and identify moments where the narrator emphasizes or references specific on-screen elements:
For each emphasis moment, record:
For each emphasis moment from the transcript, extract the source frame at that timestamp:
npx remotion ffmpeg -y -i <video_path> -vf "select=eq(n\,<frame_number>)" -vframes 1 -update 1 /tmp/frame-<timestamp>.png
Where frame_number = timestamp_seconds * fps.
Important: The video content may scroll or change over time. Always extract frames at the ACTUAL timestamp, not from a single reference frame. UI element positions change as the user scrolls.
For each extracted frame, draw a coordinate grid overlay to precisely identify element positions:
from PIL import Image, ImageDraw
img = Image.open('/tmp/frame-<timestamp>.png')
w, h = img.size
draw = ImageDraw.Draw(img)
for pct in range(0, 100, 5):
y = int(h * pct / 100)
x = int(w * pct / 100)
color = 'red' if pct % 10 == 0 else 'yellow'
draw.line([(0, y), (w, y)], fill=color, width=1)
draw.text((5, y+2), f'y{pct}', fill=color)
draw.line([(x, 0), (x, h)], fill=color, width=1)
draw.text((x+2, 12), f'x{pct}', fill=color)
img.save('/tmp/frame-<timestamp>-grid.png')
Look at the grid overlay and measure the center point (focusX, focusY) of the element the narrator is referring to. Express as normalized 0-1 values (e.g., x=0.47 means 47% from the left edge).
Create a list of keyframes. Each keyframe defines a target zoom state at a specific time. The system smoothly interpolates between consecutive keyframes using ease-in-out.
Keyframe design rules:
Zoom level guide:
| What you're showing | Zoom level |
|---|---|
| Full dashboard/page overview | 1.0 (no zoom) |
| A section (metrics row + charts) | 1.1-1.3 |
| A specific area (one chart, a few table columns) | 1.8-2.5 |
| A single metric, cell, or button | 2.8-3.5 |
Example keyframe timeline:
const KEYFRAMES = [
{ timeSec: 0, zoom: 1.0, focusX: 0.5, focusY: 0.5 }, // Full view
{ timeSec: 23, zoom: 1.0, focusX: 0.5, focusY: 0.5 }, // Still full, about to zoom
{ timeSec: 25, zoom: 1.2, focusX: 0.45, focusY: 0.20 }, // Gentle zoom on metrics area
{ timeSec: 29, zoom: 1.2, focusX: 0.45, focusY: 0.20 }, // Hold
{ timeSec: 30.5, zoom: 3.0, focusX: 0.56, focusY: 0.15 }, // Zoom on specific metric
{ timeSec: 34, zoom: 3.0, focusX: 0.56, focusY: 0.15 }, // Hold
{ timeSec: 36, zoom: 3.0, focusX: 0.06, focusY: 0.15 }, // Slide to different metric
// ... etc
];
Before doing a full render, verify every zoom target with still frames. This is the most important step — it catches coordinate errors that would waste a full render cycle.
For each keyframe where the zoom or focus changes, render a single still frame:
npx remotion still <CompositionId> --frame=<frame_number> --output=/tmp/verify-<timestamp>.png
Self-review each still frame:
Common coordinate mistakes to check for:
Create the Remotion project files. The composition structure:
Root.tsx:
import { Composition } from "remotion";
import { MyComposition } from "./Composition";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="VideoPolish"
component={MyComposition}
durationInFrames={DURATION_SECONDS * FPS}
fps={FPS}
width={VIDEO_WIDTH}
height={VIDEO_HEIGHT}
/>
);
};
Composition.tsx:
import {
AbsoluteFill, Audio, OffthreadVideo, staticFile,
useCurrentFrame, useVideoConfig,
} from "remotion";
type Keyframe = {
timeSec: number;
zoom: number;
focusX: number;
focusY: number;
};
const KEYFRAMES: Keyframe[] = [
// ... keyframes from Step 5
];
// Smooth ease-in-out interpolation
function smoothstep(t: number): number {
const c = Math.max(0, Math.min(1, t));
return c * c * (3 - 2 * c);
}
function getStateAtTime(timeSec: number) {
if (timeSec <= KEYFRAMES[0].timeSec) return KEYFRAMES[0];
if (timeSec >= KEYFRAMES[KEYFRAMES.length - 1].timeSec)
return KEYFRAMES[KEYFRAMES.length - 1];
for (let i = 0; i < KEYFRAMES.length - 1; i++) {
const kf0 = KEYFRAMES[i];
const kf1 = KEYFRAMES[i + 1];
if (timeSec >= kf0.timeSec && timeSec <= kf1.timeSec) {
const t = (timeSec - kf0.timeSec) / (kf1.timeSec - kf0.timeSec);
const e = smoothstep(t);
return {
zoom: kf0.zoom + (kf1.zoom - kf0.zoom) * e,
focusX: kf0.focusX + (kf1.focusX - kf0.focusX) * e,
focusY: kf0.focusY + (kf1.focusY - kf0.focusY) * e,
};
}
}
return KEYFRAMES[KEYFRAMES.length - 1];
}
export const MyComposition: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { zoom, focusX, focusY } = getStateAtTime(frame / fps);
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
<AbsoluteFill
style={{
transform: `scale(${zoom})`,
transformOrigin: `${focusX * 100}% ${focusY * 100}%`,
}}
>
<OffthreadVideo
src={staticFile("source.mp4")}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</AbsoluteFill>
{/* Optional: background music */}
<Audio
src={staticFile("music.mp3")}
volume={(f) => {
const total = DURATION * fps;
if (f < fps) return f / fps; // 1s fade in
if (f > total - 3 * fps)
return (total - f) / (3 * fps); // 3s fade out
return 1;
}}
/>
</AbsoluteFill>
);
};
Copy the source video (and optional music) into the Remotion project's public/ folder:
cp <source_video> <remotion_project>/public/source.mp4
cp <music_file> <remotion_project>/public/music.mp3 # optional
npx remotion render <CompositionId> --output=<output_path>.mp4
Rendering takes approximately 1-3 minutes for a 60-90 second video at 720p/1080p.
Video polished!
- Duration: [X] seconds
- Resolution: [W]x[H]
- Zoom effects: [N] zoom points
- Audio: [original / replaced with soundtrack / mixed]
- File: [local path]
Want me to adjust any zoom points and re-render?
| Input | Required | Formats | Notes |
|---|---|---|---|
| Source video | Yes | MP4, MOV, WebM | Screen recording, Loom, product demo |
| Soundtrack | No | MP3, WAV, AAC | Replaces or mixes with original audio |
| Zoom instructions | No | Natural language | "Zoom in when he talks about metrics." If not provided, the skill auto-detects from transcript. |
| Specific timestamps | No | "Zoom at 0:15, 0:42" | Overrides auto-detection for specific moments |
| Option | What happens |
|---|---|
| Keep original (default if no soundtrack provided) | Original narration plays, zoom effects are visual only |
| Replace with soundtrack | Original audio removed, soundtrack plays with fade-in/fade-out |
| Mix | Soundtrack plays at ~20% volume underneath original narration |
| Property | Value |
|---|---|
| Format | MP4 (H.264) |
| Resolution | Same as source video |
| Frame rate | Same as source video |
| Easing | Smoothstep (cubic ease-in-out) on all transitions |
| Render engine | Remotion (CSS transform-based, sub-pixel precision) |
npx create-video). Subsequent runs reuse the same project — just swap the source video and update keyframes.Take gooseworks-ai/video-polish from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip, npx, brew.
Without those the skill loads but fails at the first command.