
Content Video
- 1 installs
- 7 repo stars
- Updated April 12, 2026
- isaac-flath/agent-starter-skills
Compose videos from project assets like source videos, images, screenshots, and blog text using Remotion.
About
Composes videos from project assets such as source videos, images, screenshots, and blog text using Remotion. A developer uses it to assemble a video from existing content project assets.
- Composes videos from source videos, images, screenshots, and blog text
- Uses Remotion and ffprobe for duration detection
Content Video by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/isaac-flath/agent-starter-skills --skill content-videoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 7 |
| Last updated | April 12, 2026 |
| Repository | isaac-flath/agent-starter-skills ↗ |
What it does
Compose videos from project assets like source videos, images, screenshots, and blog text using Remotion.
Files
Content Video
Create videos from project assets (source videos, images, screenshots, blog text) using Remotion.
Prerequisites
- Node.js 18+ and npm
- ffprobe (from ffmpeg) for video duration detection
- Source video or images in the project
Workflow
1. Setup Remotion Project
uv run .claude/skills/content-video/scripts/setup_remotion.py <project-dir>This will:
- Discover assets: videos in
source/, images inimages/andscreenshots/, blog incontent/ - Create a
video/subdirectory with Remotion project scaffolding - Symlink project assets into
video/public/ - Install npm dependencies
- Generate composition code from templates
2. Edit Composition (Optional)
After setup, edit video/src/Composition.tsx to adjust:
- Overlay timing and positioning
- Text content and animations
- Sequence ordering
Preview with: cd video && npx remotion preview
3. Render Final Video
uv run .claude/skills/content-video/scripts/render_video.py <project-dir>Output: video/out/final.mp4 (1920x1080, h264, 30fps)
Templates
Root.tsx.jinja2- Remotion Root componentComposition.tsx.jinja2- Main composition with overlaysindex.ts.jinja2- Entry point
Tips
- Run
/content-imagefirst to generate overlay images - Run
/content-screenshotto extract key frames from source video - The composition template creates a base video layer with image/text overlays
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Render a Remotion video project.
Usage:
uv run .claude/skills/content-video/scripts/render_video.py <project-dir>
Runs `npx remotion render` in the project's video/ directory.
Output: video/out/final.mp4
"""
import subprocess
import sys
from pathlib import Path
def main():
if len(sys.argv) < 2:
print("Usage: uv run .claude/skills/content-video/scripts/render_video.py <project-dir>")
sys.exit(1)
project_dir = Path(sys.argv[1]).resolve()
video_dir = project_dir / "video"
if not video_dir.exists():
print(f"Error: No video/ directory found in {project_dir}")
print("Run setup_remotion.py first.")
sys.exit(1)
if not (video_dir / "node_modules").exists():
print("Installing dependencies...")
subprocess.run(
["npm", "install"],
cwd=str(video_dir),
timeout=120,
)
out_dir = video_dir / "out"
out_dir.mkdir(parents=True, exist_ok=True)
output_file = out_dir / "final.mp4"
print("Rendering video...")
print(f"Output: {output_file}")
result = subprocess.run(
[
"npx", "remotion", "render",
"src/index.ts",
"main",
str(output_file),
"--codec", "h264",
],
cwd=str(video_dir),
timeout=600,
)
if result.returncode != 0:
print("Error: Render failed.")
sys.exit(1)
if output_file.exists():
size_mb = output_file.stat().st_size / (1024 * 1024)
print(f"\nDone! Output: {output_file} ({size_mb:.1f} MB)")
else:
print("Error: Output file was not created.")
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = ["jinja2"]
# ///
"""
Set up a Remotion project from project assets.
Usage:
uv run .claude/skills/content-video/scripts/setup_remotion.py <project-dir>
Discovers source videos, images, screenshots, and blog content,
then scaffolds a Remotion project in <project-dir>/video/.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm"}
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
PACKAGE_JSON = {
"name": "raw2draft-video",
"version": "1.0.0",
"private": True,
"scripts": {
"preview": "remotion preview src/index.ts",
"render": "remotion render src/index.ts main out/final.mp4 --codec h264",
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "^4.0.0",
"typescript": "^5.0.0",
},
}
TSCONFIG = {
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": True,
"esModuleInterop": True,
"skipLibCheck": True,
"outDir": "dist",
},
"include": ["src"],
}
def find_files(directory: Path, extensions: set[str]) -> list[str]:
"""Find files with given extensions in a directory."""
if not directory.exists():
return []
return sorted(
f.name
for f in directory.iterdir()
if f.is_file() and f.suffix.lower() in extensions
)
def get_video_duration(video_path: Path) -> float:
"""Get video duration in seconds using ffprobe."""
try:
result = subprocess.run(
[
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
str(video_path),
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
data = json.loads(result.stdout)
return float(data.get("format", {}).get("duration", 60))
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
pass
return 60.0 # Default fallback
def read_blog_excerpt(content_dir: Path, max_lines: int = 10) -> list[str]:
"""Read first N non-empty lines from blog.md for text overlays."""
blog = content_dir / "blog.md"
if not blog.exists():
return []
lines = []
for line in blog.read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#") and not stripped.startswith("!["):
lines.append(stripped)
if len(lines) >= max_lines:
break
return lines
def main():
if len(sys.argv) < 2:
print("Usage: uv run .claude/skills/content-video/scripts/setup_remotion.py <project-dir>")
sys.exit(1)
project_dir = Path(sys.argv[1]).resolve()
if not project_dir.exists():
print(f"Error: Project directory not found: {project_dir}")
sys.exit(1)
source_dir = project_dir / "source"
images_dir = project_dir / "images"
screenshots_dir = project_dir / "screenshots"
content_dir = project_dir / "content"
video_dir = project_dir / "video"
# Discover assets
source_videos = find_files(source_dir, VIDEO_EXTENSIONS)
images = find_files(images_dir, IMAGE_EXTENSIONS)
screenshots = find_files(screenshots_dir, IMAGE_EXTENSIONS)
blog_lines = read_blog_excerpt(content_dir)
print(f"Found {len(source_videos)} source video(s)")
print(f"Found {len(images)} image(s)")
print(f"Found {len(screenshots)} screenshot(s)")
print(f"Found {len(blog_lines)} blog excerpt line(s)")
if not source_videos and not images and not screenshots:
print("\nWarning: No assets found. Add source videos or run /content-image first.")
if not images and not screenshots:
print("\nTip: Run /content-image or /content-screenshot to generate overlay images.")
# Get duration of first source video
duration_seconds = 60.0
if source_videos:
first_video = source_dir / source_videos[0]
duration_seconds = get_video_duration(first_video)
print(f"Source video duration: {duration_seconds:.1f}s")
fps = 30
total_frames = int(duration_seconds * fps)
# Create video directory structure
video_src = video_dir / "src"
video_public = video_dir / "public"
video_out = video_dir / "out"
for d in [video_src, video_public, video_out]:
d.mkdir(parents=True, exist_ok=True)
# Write package.json and tsconfig.json
(video_dir / "package.json").write_text(json.dumps(PACKAGE_JSON, indent=2))
(video_dir / "tsconfig.json").write_text(json.dumps(TSCONFIG, indent=2))
print("Created package.json and tsconfig.json")
# Symlink assets into public/
def symlink_dir(src: Path, dest_name: str):
dest = video_public / dest_name
if dest.exists() or dest.is_symlink():
dest.unlink() if dest.is_symlink() else None
if src.exists():
dest.symlink_to(src.resolve())
print(f"Linked {dest_name}/ -> {src}")
symlink_dir(source_dir, "source")
symlink_dir(images_dir, "images")
symlink_dir(screenshots_dir, "screenshots")
# Render templates
templates_dir = Path(__file__).parent.parent / "templates"
env = Environment(loader=FileSystemLoader(str(templates_dir)))
template_context = {
"source_videos": source_videos,
"images": images,
"screenshots": screenshots,
"blog_lines": blog_lines,
"duration_seconds": duration_seconds,
"fps": fps,
"total_frames": total_frames,
"width": 1920,
"height": 1080,
}
for template_name, output_path in [
("index.ts.jinja2", video_src / "index.ts"),
("Root.tsx.jinja2", video_src / "Root.tsx"),
("Composition.tsx.jinja2", video_src / "Composition.tsx"),
]:
template = env.get_template(template_name)
output_path.write_text(template.render(**template_context))
print(f"Generated {output_path.relative_to(video_dir)}")
# Install dependencies
print("\nInstalling npm dependencies...")
result = subprocess.run(
["npm", "install"],
cwd=str(video_dir),
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
print(f"Warning: npm install failed:\n{result.stderr}")
else:
print("Dependencies installed successfully.")
print(f"\nReady! Preview with: cd {video_dir} && npx remotion preview")
print(f"Render with: uv run .claude/skills/content-video/scripts/render_video.py {project_dir}")
if __name__ == "__main__":
main()
import {
AbsoluteFill,
Img,
interpolate,
Sequence,
staticFile,
useCurrentFrame,
{% if source_videos %}
Video,
{% endif %}
} from "remotion";
const TextOverlay: React.FC<{{'{'}} text: string {{'}'}}> = ({{'{'}} text {{'}'}}) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, 15], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
style={{'{'}}{{'{'}}{{'}'}}
position: "absolute",
bottom: 80,
left: 60,
right: 60,
backgroundColor: "rgba(0, 0, 0, 0.7)",
color: "#fff",
padding: "20px 30px",
borderRadius: 12,
fontSize: 32,
fontFamily: "Inter, sans-serif",
lineHeight: 1.4,
opacity,
{{'}'}}{{'}'}}
>
{{'{'}}text{{'}'}}
</div>
);
};
const ImageOverlay: React.FC<{{'{'}} src: string {{'}'}}> = ({{'{'}} src {{'}'}}) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, 15], [0, 1], {
extrapolateRight: "clamp",
});
const scale = interpolate(frame, [0, 20], [0.95, 1], {
extrapolateRight: "clamp",
});
return (
<div
style={{'{'}}{{'{'}}{{'}'}}
position: "absolute",
top: 60,
right: 60,
opacity,
transform: `scale(${{'{'}}{{'{'}}scale{{'}'}}{{'}'}})`
{{'}'}}{{'}'}}
>
<Img
src={{'{'}}staticFile(src){{'}'}}
style={{'{'}}{{'{'}}{{'}'}}
maxWidth: 500,
maxHeight: 400,
borderRadius: 12,
boxShadow: "0 10px 30px rgba(0,0,0,0.5)",
{{'}'}}{{'}'}}
/>
</div>
);
};
export const MainComposition: React.FC = () => {
return (
<AbsoluteFill style={{'{'}}{{'{'}}{{'}'}} backgroundColor: "#000" {{'}'}}{{'}'}}>{%- if source_videos %}
{{'{'}}/* Base video layer */{{'}'}}
<Video
src={{'{'}}staticFile("source/{{ source_videos[0] }}"){{'}'}}
style={{'{'}}{{'{'}}{{'}'}} width: "100%", height: "100%", objectFit: "contain" {{'}'}}{{'}'}}
/>{% endif %}
{% set ns = namespace(frame_offset=30) %}
{%- for img in screenshots %}
{{'{'}}/* Screenshot overlay: {{ img }} */{{'}'}}
<Sequence from={{'{'}}{{ ns.frame_offset }}{{'}'}} durationInFrames={{'{'}}{{ fps * 5 }}{{'}'}}>
<ImageOverlay src="screenshots/{{ img }}" />
</Sequence>
{% set ns.frame_offset = ns.frame_offset + fps * 8 %}
{%- endfor %}
{%- for img in images %}
{{'{'}}/* Image overlay: {{ img }} */{{'}'}}
<Sequence from={{'{'}}{{ ns.frame_offset }}{{'}'}} durationInFrames={{'{'}}{{ fps * 5 }}{{'}'}}>
<ImageOverlay src="images/{{ img }}" />
</Sequence>
{% set ns.frame_offset = ns.frame_offset + fps * 8 %}
{%- endfor %}
{%- for line in blog_lines %}
{{'{'}}/* Text overlay */{{'}'}}
<Sequence from={{'{'}}{{ ns.frame_offset }}{{'}'}} durationInFrames={{'{'}}{{ fps * 4 }}{{'}'}}>
<TextOverlay text="{{ line | replace('"', '\\"') }}" />
</Sequence>
{% set ns.frame_offset = ns.frame_offset + fps * 6 %}
{%- endfor %}
</AbsoluteFill>
);
};
import { registerRoot } from "remotion";
import { Root } from "./Root";
registerRoot(Root);
import { Composition } from "remotion";
import { MainComposition } from "./Composition";
export const Root: React.FC = () => {
return (
<Composition
id="main"
component={MainComposition}
durationInFrames={{'{'}}{{ total_frames }}{{'}'}}
fps={{'{'}}{{ fps }}{{'}'}}
width={{'{'}}{{ width }}{{'}'}}
height={{'{'}}{{ height }}{{'}'}}
/>
);
};