Documentary Montage hardening plus governance fixes

This commit is contained in:
calesthio
2026-04-10 16:42:39 -07:00
parent 44baede67f
commit de94d4dba3
27 changed files with 1107 additions and 98 deletions
+1
View File
@@ -28,6 +28,7 @@ MODAL_LTX2_ENDPOINT_URL= # Modal self-hosted LTX-2 endpoint (optional)
# --- Stock Media ---
PEXELS_API_KEY= # Pexels stock footage/images (free)
PIXABAY_API_KEY= # Pixabay stock footage/images (free)
UNSPLASH_ACCESS_KEY= # Unsplash stock images (free developer key)
# --- Analysis ---
HF_TOKEN= # HuggingFace token — enables speaker diarization in transcriber
+36 -8
View File
@@ -31,6 +31,8 @@
Turn your AI coding assistant into a full video production studio. Describe what you want in plain language — your agent handles research, scripting, asset generation, editing, and final composition.
**Important distinction:** OpenMontage can make image-based videos, but it can also make a real **video video** for free/open-source workflows: the agent builds a corpus from free stock footage and open archives, retrieves actual motion clips, edits them into a timeline, and renders a finished piece. That is not the usual "animate a handful of stills and call it video" trick.
<div align="center">
<video src="https://github.com/user-attachments/assets/f77ce7a4-68b8-4f94-a287-e94bf50a32e1" width="100%" controls></video>
</div>
@@ -117,6 +119,12 @@ Open the project in your AI coding assistant and tell it what you want:
"Make a 60-second animated explainer about how neural networks learn"
```
Or if you want the real-footage path:
```text
"Make a 75-second documentary montage about city life in the rain. Use real footage only, no narration, elegiac tone, with music."
```
That's it. The agent researches your topic with live web search, generates AI images, writes and narrates the script with voice direction, finds royalty-free background music automatically, burns in word-level subtitles, and renders the final video. Before you see anything, the system runs a multi-point self-review — ffprobe validation, frame sampling, audio level analysis, delivery promise verification, and subtitle checks. Every provider selection is scored across 7 dimensions with an auditable decision log. Every creative decision gets your approval.
> **No `make`?** Run manually: `pip install -r requirements.txt && cd remotion-composer && npm install && cd .. && pip install piper-tts && cp .env.example .env`
@@ -149,8 +157,9 @@ This repo is built for agentic operation. If you're an OpenClaw-style agent, her
FAL_KEY=your-key # FLUX images + Google Veo, Kling, MiniMax video + Recraft images
# Free stock media:
PEXELS_API_KEY=your-key # Free stock footage and images
PIXABAY_API_KEY=your-key # Free stock footage and images
PEXELS_API_KEY=your-key # Free stock footage and images
PIXABAY_API_KEY=your-key # Free stock footage and images
UNSPLASH_ACCESS_KEY=your-key # Free stock images
# Music:
SUNO_API_KEY=your-key # Full songs, instrumentals, any genre
@@ -183,17 +192,23 @@ VIDEO_GEN_LOCAL_MODEL=wan2.1-1.3b # or wan2.1-14b, hunyuan-1.5, ltx2-local, cog
## What You Get With Zero API Keys
You don't need any API keys to make real videos. Out of the box, `make setup` gives you:
You don't need paid API keys to make real videos. Out of the box, `make setup` gives you:
| Capability | Free Tool | What It Does |
|-----------|-----------|-------------|
| **Narration** | Piper TTS | Free offline text-to-speech — real human-sounding narration |
| **Visuals** | Pexels + Pixabay | Free stock images and footage (API keys are free to get) |
| **Open footage** | Archive.org + NASA + Wikimedia Commons | Free/open archival footage, educational media, and documentary texture |
| **Extra stock** | Pexels + Unsplash + Pixabay | Free stock footage/images (developer keys are free to get) |
| **Composition** | Remotion | Turns still images into animated video with spring physics, transitions, typography, and TikTok-style captions |
| **Post-production** | FFmpeg | Encoding, subtitle burn-in, audio mixing, color grading |
| **Subtitles** | Built-in | Auto-generated captions with word-level timing |
**The zero-key path:** Piper narrates your script, stock images provide the visuals, and Remotion animates everything into a polished video with transitions, text overlays, and synced captions. Add API keys later to unlock AI-generated images, video clips, premium voices, and music.
**Two free-ish paths:**
- **Image-based video:** Piper narrates your script, images provide the visuals, and Remotion animates them into a polished edit.
- **Real-footage video:** the documentary montage pipeline builds a CLIP-searchable corpus from Archive.org, NASA, Wikimedia Commons, and optional free-key sources like Pexels and Unsplash, then cuts together actual motion footage into a finished video.
If you want the second one, prompt for a **documentary montage**, **tone poem**, or **stock-footage collage**, and explicitly say **use real footage only**.
---
@@ -217,6 +232,14 @@ Copy any of these into your AI coding assistant after setup. Each one runs a ful
> "Make a data-driven explainer about coffee consumption around the world"
### Free real-footage documentary path
> "Make a 90-second documentary montage about what a city feels like at 4am. Use real footage only, no narration, elegiac tone."
> "Create a 60-second Adam-Curtis-style archival collage about 1950s consumer optimism. Prefer Archive.org and Wikimedia footage."
> "Cut together a dreamlike montage about coming home in the rain using real stock footage only. Music yes, narration no."
### With an image/video provider configured (~$0.15$1.50)
> "Create a 30-second Ghibli-style animated video of a magical floating library in the clouds at golden hour"
@@ -248,7 +271,7 @@ Each pipeline is a complete production workflow, from idea to finished video.
| **Avatar Spokesperson** | Avatar-driven presenter videos | Corporate comms, training, announcements |
| **Cinematic** | Trailer, teaser, and mood-driven edits | Brand films, teasers, promotional content |
| **Clip Factory** | Batch of ranked short-form clips from one long source | Repurposing long content for social media |
| **Documentary Montage** | Thematic montage cut from a CLIP-indexed corpus of free stock footage (Pexels, Archive.org, NASA) | Video essays, mood pieces, retrieval-first B-roll edits |
| **Documentary Montage** | Thematic montage cut from a CLIP-indexed corpus of free stock footage and open archives (Pexels, Archive.org, NASA, Wikimedia, Unsplash) | Video essays, mood pieces, retrieval-first B-roll edits, real-footage videos without paid generation APIs |
| **Hybrid** | Source footage + AI-generated support visuals | Enhancing existing footage with graphics |
| **Localization & Dub** | Subtitle, dub, and translate existing video | Multi-language distribution |
| **Podcast Repurpose** | Podcast highlights to video | Podcast marketing, audiogram videos |
@@ -271,12 +294,15 @@ Each stage has a dedicated **director skill** — a markdown instruction file th
Most AI video tools give you a single clip from a prompt. OpenMontage gives you an **end-to-end production pipeline** — the same structured process a real production team follows, automated by your AI agent.
Most "free AI video" stacks quietly mean "animate still images." OpenMontage can do that too, but it can also build a finished video from **real footage** pulled from free/open sources, ranked semantically, edited intentionally, and rendered as a proper timeline.
Edit your own talking-head footage. Generate a fully animated explainer from scratch. Cut a 2-hour podcast into a dozen social clips. Translate and dub your content into 10 languages. Build a cinematic brand teaser from stock footage and AI-generated scenes. **If a production team can make it, OpenMontage can orchestrate it.**
- **12 production pipelines** — explainers, talking heads, screen demos, cinematic trailers, animations, podcasts, localization, documentary montages, and more
- **52 production tools** — spanning video generation, image creation, text-to-speech, music, audio mixing, subtitles, enhancement, and analysis
- **400+ agent skills** — production skills, pipeline directors, creative techniques, quality checklists, and deep technology knowledge packs that teach the agent how to use every tool like an expert
- **Reference-driven creation** — paste a video you like and the agent turns it into a grounded, differentiated production plan instead of forcing you to invent the perfect prompt from scratch
- **Real-footage documentary creation without paid video models** — build actual edited videos from free/open motion footage and archival sources, not just Ken Burns over images
- **Live web research built in** — before writing a single word of script, the agent runs 15-25+ web searches across YouTube, Reddit, news sites, and academic sources to ground your video in real, current data
- **Both free/local AND cloud providers** — every capability supports open-source local alternatives alongside premium APIs. Use what you have.
- **No vendor lock-in** — swap providers freely. The scored selector ranks every provider across 7 dimensions (task fit, output quality, control, reliability, cost efficiency, latency, continuity) and picks the best match automatically.
@@ -371,7 +397,7 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to
> **Full setup guide with pricing and free tiers:** [`docs/PROVIDERS.md`](docs/PROVIDERS.md)
<details>
<summary><strong>Video Generation — 13 providers</strong></summary>
<summary><strong>Video Generation — 14 providers</strong></summary>
| Provider | Type | Notes |
|----------|------|-------|
@@ -388,11 +414,12 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to
| **LTX-Video** | Local GPU / Modal | Free locally, or self-hosted cloud |
| **Pexels** | Stock | Free stock footage |
| **Pixabay** | Stock | Free stock footage |
| **Wikimedia Commons** | Stock | Free/open stock footage and archival video |
</details>
<details>
<summary><strong>Image Generation — 9 tools/providers</strong></summary>
<summary><strong>Image Generation — 10 tools/providers</strong></summary>
| Provider | Type | Notes |
|----------|------|-------|
@@ -404,6 +431,7 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to
| **Local Diffusion** | Local GPU | Stable Diffusion, free |
| **Pexels** | Stock | Free stock images |
| **Pixabay** | Stock | Free stock images |
| **Unsplash** | Stock | Free stock images |
| **ManimCE** | Local | Mathematical animations |
</details>
+3 -1
View File
@@ -2,7 +2,8 @@ name: documentary-montage
version: "1.0"
description: >
Retrieval-first thematic montage pipeline. Builds a semantic corpus of real-world
footage from Pexels, Archive.org (Prelinger et al.), and NASA, then uses CLIP-based
footage from Pexels, Archive.org (Prelinger et al.), NASA, Wikimedia Commons, and
Unsplash, then uses CLIP-based
retrieval to fill slot descriptions from a thematic brief. The edit arranges clips
by narrative beat with music sync and uniform color grade across mixed-era footage.
Inspired by Adam Curtis / Chris Marker / Errol Morris tone poems.
@@ -120,6 +121,7 @@ stages:
checkpoint_required: true
human_approval_default: true
review_focus:
- renderer_family is locked to documentary-montage
- Hero slots hold longest; mid-sequence cutaways shortest
- No two adjacent cuts share subject AND scale
- Transition vocabulary is at most 4 distinct values
+22
View File
@@ -6,6 +6,10 @@ import {
} from "./CinematicRenderer";
import { signalFromTomorrowWithMusicFixture } from "./cinematic/fixtures";
import { TalkingHead, TalkingHeadProps } from "./TalkingHead";
import {
TitledVideo,
calculateTitledVideoMetadata,
} from "./TitledVideo";
// ---------------------------------------------------------------------------
// Theme System — prevents every video from looking like dark fintech
@@ -181,6 +185,24 @@ export const Root: React.FC = () => {
highlightColor: "#22D3EE",
}}
/>
<Composition
id="TitledVideo"
component={TitledVideo}
durationInFrames={30 * 60}
fps={30}
width={1920}
height={1080}
defaultProps={{
videoSrc: "",
tagline: "home is a verb.",
taglineInSeconds: 53.5,
taglineOutSeconds: undefined,
topPx: 150,
fontSize: 148,
accentColor: "#F5C470",
}}
calculateMetadata={calculateTitledVideoMetadata}
/>
</>
);
};
+263
View File
@@ -0,0 +1,263 @@
import {
AbsoluteFill,
CalculateMetadataFunction,
OffthreadVideo,
Sequence,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { getVideoMetadata } from "@remotion/media-utils";
import { loadFont } from "@remotion/google-fonts/PlayfairDisplay";
// Editorial serif for the tagline — Playfair Display at its boldest weight.
// Loaded once at module scope so every render reuses the same font face.
const { fontFamily } = loadFont("normal", {
weights: ["400", "700", "900"],
subsets: ["latin"],
});
export interface TitledVideoProps {
videoSrc: string;
tagline: string;
// When the tagline starts animating in, in seconds from the start of the video.
taglineInSeconds: number;
// When the tagline should be fully gone. If omitted, it holds to the end of the clip.
taglineOutSeconds?: number;
// Pixels from the top of the frame where the tagline sits. Upper-third by default.
topPx?: number;
// Optional override for the font size.
fontSize?: number;
// Accent color used for the underline and the glow halo.
accentColor?: string;
}
// Resolve asset path — handle URLs, absolute paths, and public/ relative paths.
// Mirrors the helper in Explainer.tsx so absolute Windows/Unix paths work.
function resolveAsset(src: string): string {
if (
src.startsWith("http://") ||
src.startsWith("https://") ||
src.startsWith("data:")
) {
return src;
}
const clean = src.replace(/^file:\/\/\/?/, "");
if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) {
return `file:///${clean.replace(/\\/g, "/")}`;
}
return staticFile(clean);
}
// ---------------------------------------------------------------------------
// EditorialTagline — big bold serif, upper-third, drawn underline, warm glow.
// Letter-by-letter spring entrance. Designed to feel like a printed headline,
// not a subtitle.
// ---------------------------------------------------------------------------
const EditorialTagline: React.FC<{
text: string;
topPx: number;
fontSize: number;
accentColor: string;
}> = ({ text, topPx, fontSize, accentColor }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const chars = text.split("");
// Hold the title until the last 8 frames, then ease out.
const fadeOut = interpolate(
frame,
[durationInFrames - 10, durationInFrames - 2],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Underline sweeps in after ~12 frames so the eye lands on the words first.
const underline = spring({
frame: frame - 12,
fps,
config: { damping: 18, stiffness: 70 },
});
// Glow pulses gently on a long period so the shine reads without flickering.
const glowPulse =
0.6 + 0.4 * Math.sin((frame / fps) * Math.PI * 0.6);
// Width target for the underline — measured in CSS px.
// We let it grow up to 70% of a max line width so it visually underscores
// the phrase no matter how many characters the tagline has.
const estimatedTextWidth = Math.min(
chars.length * fontSize * 0.48,
1600
);
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center",
pointerEvents: "none",
}}
>
{/* Soft top-of-frame darkening so the bright type sits on a scrim
without covering the whole video. Gradient fades out by ~35% */}
<AbsoluteFill
style={{
background:
"linear-gradient(to bottom, rgba(10,8,6,0.55) 0%, rgba(10,8,6,0.25) 22%, rgba(10,8,6,0) 40%)",
opacity: fadeOut,
}}
/>
<div
style={{
position: "absolute",
top: topPx,
left: 0,
right: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
opacity: fadeOut,
}}
>
{/* Tagline text */}
<div
style={{
fontFamily,
fontWeight: 900,
fontSize,
lineHeight: 1.02,
letterSpacing: "-0.015em",
color: "#FFF8EC",
textAlign: "center",
// Warm editorial glow — double drop-shadow gives a print-ink feel.
textShadow: `
0 2px 0 rgba(0,0,0,0.45),
0 8px 28px rgba(0,0,0,0.55),
0 0 ${24 + glowPulse * 24}px ${accentColor}80,
0 0 ${60 + glowPulse * 40}px ${accentColor}40
`,
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
}}
>
{chars.map((char, i) => {
const delay = i * 1.6;
const charSpring = spring({
frame: frame - delay,
fps,
config: { damping: 14, stiffness: 140 },
});
const dy = interpolate(charSpring, [0, 1], [38, 0]);
return (
<span
key={i}
style={{
display: "inline-block",
opacity: charSpring,
transform: `translateY(${dy}px)`,
whiteSpace: char === " " ? "pre" : undefined,
minWidth: char === " " ? "0.28em" : undefined,
}}
>
{char}
</span>
);
})}
</div>
{/* Animated underline — draws in from the center outward */}
<div
style={{
marginTop: Math.round(fontSize * 0.18),
height: Math.max(4, Math.round(fontSize * 0.055)),
width: estimatedTextWidth * underline,
background: `linear-gradient(90deg, ${accentColor}00 0%, ${accentColor} 20%, ${accentColor} 80%, ${accentColor}00 100%)`,
borderRadius: 4,
boxShadow: `0 0 ${18 + glowPulse * 18}px ${accentColor}cc`,
}}
/>
</div>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// TitledVideo composition — plays a prerendered clip full-bleed and overlays
// the editorial tagline during a window of the timeline.
// ---------------------------------------------------------------------------
export const TitledVideo: React.FC<TitledVideoProps> = ({
videoSrc,
tagline,
taglineInSeconds,
taglineOutSeconds,
topPx = 150,
fontSize = 148,
accentColor = "#F5C470",
}) => {
const { fps, durationInFrames } = useVideoConfig();
const inFrame = Math.max(0, Math.round(taglineInSeconds * fps));
const endFrame =
taglineOutSeconds !== undefined
? Math.round(taglineOutSeconds * fps)
: durationInFrames;
const overlayFrames = Math.max(1, endFrame - inFrame);
return (
<AbsoluteFill style={{ background: "#000" }}>
{/* Full-bleed background video — no fades, no vignette, no color shift.
The source is already color-graded final.mp4 with music baked in;
we play it through untouched, audio included. */}
<OffthreadVideo
src={resolveAsset(videoSrc)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Tagline overlay in its own Sequence so it mounts exactly on the
fade-in frame and carries its own local frame counter. */}
<Sequence from={inFrame} durationInFrames={overlayFrames}>
<EditorialTagline
text={tagline}
topPx={topPx}
fontSize={fontSize}
accentColor={accentColor}
/>
</Sequence>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// calculateMetadata — probe the source clip so the composition runs at the
// clip's native duration. Falls back to 60s at 30fps if probing fails.
// ---------------------------------------------------------------------------
export const calculateTitledVideoMetadata: CalculateMetadataFunction<
TitledVideoProps
> = async ({ props }) => {
try {
const meta = await getVideoMetadata(resolveAsset(props.videoSrc));
return {
durationInFrames: Math.max(1, Math.round(meta.durationInSeconds * 30)),
fps: 30,
width: 1920,
height: 1080,
};
} catch {
return {
durationInFrames: 30 * 60,
fps: 30,
width: 1920,
height: 1080,
};
}
};
@@ -0,0 +1,8 @@
{
"videoSrc": "walk-home/final.mp4",
"tagline": "home is a verb.",
"taglineInSeconds": 53.5,
"topPx": 150,
"fontSize": 148,
"accentColor": "#F5C470"
}
+1 -1
View File
@@ -191,7 +191,7 @@
},
"renderer_family": {
"type": "string",
"enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "product-reveal", "screen-demo", "presenter", "animation-first"],
"enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "documentary-montage", "product-reveal", "screen-demo", "presenter", "animation-first"],
"description": "Locked at proposal stage — determines which Remotion composition renders this video"
},
"slideshow_risk_score": {
@@ -147,7 +147,7 @@
},
"renderer_family": {
"type": "string",
"enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "product-reveal", "screen-demo", "presenter", "animation-first"],
"enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "documentary-montage", "product-reveal", "screen-demo", "presenter", "animation-first"],
"description": "Locked at proposal stage — compose cannot change without logging a decision"
},
"music_source": {
+1 -1
View File
@@ -39,7 +39,7 @@
"render_grammar": {
"type": "string",
"description": "Which renderer family was used for this render",
"enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "product-reveal", "screen-demo", "presenter", "animation-first"]
"enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "documentary-montage", "product-reveal", "screen-demo", "presenter", "animation-first"]
},
"slideshow_risk_score": {
"type": "object",
@@ -6,7 +6,7 @@ The shot list exists. You now have to actually go out and find the
clips that fill each slot. This is a two-step operation:
1. **Build the corpus** — fan the scene director's queries out across
Pexels / Archive.org / NASA and download/embed the candidates.
Pexels / Archive.org / NASA / Wikimedia / Unsplash and download/embed the candidates.
2. **Pick per slot** — run CLIP retrieval against the corpus with each
slot description and choose one winner per slot.
@@ -71,7 +71,7 @@ corpus_builder.execute({
{"query": "taxi heavy rain yellow", "kind": "video", "per_source": 6},
# ... one entry per unique slot query
],
"sources": ["pexels", "archive_org"], # from preferred_sources union
"sources": ["pexels", "archive_org", "wikimedia"], # from preferred_sources union
"filters": {
"min_duration": 3,
"max_duration": 40,
@@ -85,6 +85,9 @@ corpus_builder.execute({
**Rules for the fan-out:**
- If the brief pins a source and `corpus_builder.source_provider_menu`
says that source is unavailable, STOP and surface it. Do not silently
drop to the remaining sources.
- Budget the corpus for 8-12x the slot count. A 15-slot montage wants
~150 candidates so retrieval has real choices.
- `per_source` of 4-8 per query is usually enough. Pushing to 20+
@@ -94,6 +97,8 @@ corpus_builder.execute({
is slow — don't interleave it with the modern Pexels batch.
- If any slot has `nasa` in `preferred_sources`, run ONE small
`nasa`-only batch. NASA is slow and its results are niche.
- `unsplash` is image-only. Use it as a support source, not the
backbone of a motion-led documentary cut.
### 3. Sanity-Check The Corpus Before Retrieval
@@ -16,7 +16,7 @@ The output is a single mp4 plus a `render_report` artifact.
| Schema | `schemas/artifacts/render_report.schema.json` | Artifact validation |
| Prior artifact | `state.artifacts["edit"]["edit_decisions"]` | Cuts, transitions, music, metadata hints |
| Prior artifact | `state.artifacts["assets"]["asset_manifest"]` | File paths, durations, providers |
| Tool | `video_compose` (FFmpeg + Remotion) | Primary render engine |
| Tool | `video_compose` (Remotion-first + FFmpeg fallback) | Primary render engine |
| Tool | `audio_mixer` | Music fade, silence window, L-cuts |
| Tool (optional) | `color_grade` | Uniform LUT across mixed-era clips |
| Tool (optional) | `video_trimmer`, `video_stitch` | Lower-level helpers if needed |
@@ -47,11 +47,16 @@ If the brief said "no narration" and a narration track somehow
appeared in the edit, STOP and ask. Do not render over a contract
violation.
Also confirm the render engine you intend to use is actually
available — `video_compose` in FFmpeg-only mode is fine for this
pipeline (the whole piece is footage-led, no Remotion scenes needed
unless the user asked for title cards). FFmpeg alone can render this
pipeline end to end.
Also confirm that `edit_decisions.renderer_family` is locked to
`documentary-montage` and that the chosen render engine preserves that
decision. For this repo's governance model, `video_compose` is
Remotion-first on `operation="render"`, even for footage-led pieces.
- If Remotion is available, use the normal `render` path and keep the
approved renderer family.
- If Remotion is unavailable, do NOT quietly drop to FFmpeg. Surface
the engine change and get approval before using a lower-level
FFmpeg-only path.
### 1. Resolve The Canvas
@@ -83,32 +88,8 @@ For a pipeline this simple, the cleanest path is:
video_compose.execute({
"operation": "render",
"output_path": "projects/<name>/renders/final.mp4",
"canvas": { "width": 1920, "height": 1080, "fps": 24 },
"cuts": [
{
"source": "<resolved file path>",
"in": 1.2,
"out": 5.2,
"scale": "fit_canvas_center_crop",
"transition_in": "fade",
"transition_in_duration": 0.8,
},
# ... more cuts
],
"audio": {
"music_path": "<resolved music path>",
"music_volume": 0.7,
"music_fade_in": 1.0,
"music_fade_out": 4.0,
"silence_windows": [{"start": 54.0, "end": 56.0}],
"sfx_layers": [
{"source": "<rain carry clip>", "start": 20.8, "duration": 1.2, "volume": 0.6}
],
},
"frame_treatment": {
"lut_path": "styles/luts/warm_film_100.cube",
"letterbox": "2.35:1"
}
"edit_decisions": edit_decisions_with_renderer_family,
"asset_manifest": asset_manifest,
})
```
@@ -116,6 +97,9 @@ The exact field names come from the live `video_compose` schema at
render time — consult the tool's `agent_skills` if available before
writing the call. Do not invent parameters.
`edit_decisions_with_renderer_family` means the normal edit artifact
with `renderer_family = "documentary-montage"` preserved intact.
### 3. Apply Grade Via LUT, Not Per Clip
Read `edit_decisions.metadata.grade_profile`. Map it to a LUT file:
@@ -163,8 +147,8 @@ Recommended encoder settings for doc montage:
| Audio codec | `aac` | Universal |
| Audio bitrate | `192k` | Music-bed friendly |
If the source clips are 30fps and the canvas is 24fps, let FFmpeg
drop frames evenly — don't blend. Motion interpolation on
If the source clips are 30fps and the canvas is 24fps, let the render
pipeline drop frames evenly — don't blend. Motion interpolation on
mixed-source footage looks awful.
### 6. Post-Render Verification
@@ -210,7 +194,7 @@ Record verifications in `render_report.verification_notes`.
"Silence window 54-56s confirmed (music -60dB)",
"Last frame fades to black at 89.0s"
],
"render_grammar": "cinematic-trailer",
"render_grammar": "documentary-montage",
"metadata": {
"pipeline": "documentary-montage",
"canvas": { "width": 1920, "height": 1080 },
@@ -247,10 +231,9 @@ Record verifications in `render_report.verification_notes`.
- **Per-clip color grading.** One LUT across the whole piece. Do
not try to balance each clip individually — it takes 10x the time
and makes the register LESS consistent, not more.
- **Quiet render engine swap.** If `video_compose` routes through
Remotion for some reason and the aesthetic changes, stop and
surface. This pipeline is FFmpeg-friendly and shouldn't need
Remotion unless the user asked for title cards.
- **Quiet FFmpeg downgrade.** If Remotion is blocked and you route to
FFmpeg without surfacing it, you've changed the approved render path.
Stop and surface that downgrade before rendering.
- **Overriding edit decisions at render time.** If you find yourself
adjusting volumes, fades, or trims in the render call, you're
editing during compose. Go back to the edit stage, fix the
@@ -34,6 +34,10 @@ The enemy is "slideshow" — a sequence of clips played back-to-back
with the same hold length and no sound design. If it feels like a
slideshow, the edit has failed, regardless of how good the clips are.
This stage also locks the render grammar. For documentary montage,
set `renderer_family` to `documentary-montage` so compose stays on the
approved Remotion-first path.
## Process
### 0. Guardrails — No Silent Major Changes
@@ -228,6 +232,7 @@ Canonical shape for this pipeline:
```json
{
"version": "1.0",
"renderer_family": "documentary-montage",
"cuts": [
{
"id": "cut_01",
@@ -284,6 +289,7 @@ Canonical shape for this pipeline:
- `sum(out - in for cut in cuts)` is within ±10% of
`brief.duration_seconds`.
- `renderer_family = "documentary-montage"` is present and unchanged.
- Hero slots have the longest holds.
- No two adjacent cuts share subject AND scale.
- The transition vocabulary is at most 4 distinct values.
@@ -55,7 +55,7 @@ before starting the stage.
| Tool | Role |
|------|------|
| `corpus_builder` | Fans out across Pexels/Archive.org/NASA, downloads + embeds + indexes |
| `corpus_builder` | Fans out across Pexels/Archive.org/NASA/Wikimedia/Unsplash, downloads + embeds + indexes |
| `clip_search` | Ranks clips for a slot, finds similar sets, diversifies selections |
| `video_compose` / Remotion | Renders the final timeline |
@@ -113,8 +113,9 @@ open for the scene director to decide per slot.
- Tone is ONE register from the fixed list.
- Duration and shape are concrete numbers / enum values.
- Music source is named OR the brief explicitly says "no music".
- Sources list is non-empty and at least one is `available` per the
tool registry.
- Sources list is non-empty and at least one requested source is
`available` per `corpus_builder.source_provider_menu` surfaced in
preflight.
## Common Pitfalls
@@ -5,7 +5,7 @@
The brief exists. You now have to turn a thematic question into a
concrete list of SLOTS the retrieval layer can fill. Each slot is an
intention ("a silhouette at a doorway at dusk") plus the queries that
will find it in the real world (Pexels/Archive.org/NASA).
will find it in the real world (Pexels/Archive.org/NASA/Wikimedia/Unsplash).
This is the most creative stage in the pipeline. Retrieval is only as
good as the slot descriptions you write.
@@ -147,6 +147,8 @@ based on what footage lives where:
| `pexels` | Modern HD footage, clean shots, people, cities, nature | Default for modern/any era |
| `archive_org` | Prelinger home movies, mid-century educational film, 1940s-1980s texture | Vintage, wry, dreamlike, anything nostalgic |
| `nasa` | Earth-from-orbit, astronomy, flight, scale imagery | Reverent, anything about scale, space, planet, flight |
| `wikimedia` | Commons photos and CC video, civic/documentary/public-event coverage | Public spaces, landmarks, protests, city texture, educational footage |
| `unsplash` | Polished editorial stills, lifestyle, product-adjacent photography | Modern still-image support shots when motion footage is thin |
If `era_mix = "vintage"`, bias slots toward `archive_org` and write
queries in period-appropriate vocabulary ("commuter", "housewife",
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from pathlib import Path
from tools.base_tool import ToolStatus
from tools.tool_registry import ToolRegistry
from tools.video.corpus_builder import CorpusBuilder
from tools.video.video_compose import VideoCompose
class _DummySource:
def __init__(self, name: str, available: bool) -> None:
self.name = name
self._available = available
def is_available(self) -> bool:
return self._available
def search(self, query: str, filters): # pragma: no cover - protocol stub
return []
def download(self, candidate, out_path: Path): # pragma: no cover - protocol stub
return out_path
def test_corpus_builder_reports_source_level_discoverability(monkeypatch):
import tools.video.stock_sources as stock_sources
monkeypatch.setattr(
stock_sources,
"all_sources",
lambda: [_DummySource("pexels", False), _DummySource("archive_org", True)],
)
monkeypatch.setattr(
stock_sources,
"available_sources",
lambda: [_DummySource("archive_org", True)],
)
monkeypatch.setattr(
stock_sources,
"source_catalog",
lambda: [
{"name": "pexels", "status": "unavailable"},
{"name": "archive_org", "status": "available"},
],
)
monkeypatch.setattr(
stock_sources,
"source_summary",
lambda: {
"configured": 1,
"total": 2,
"available_source_names": ["archive_org"],
"unavailable_source_names": ["pexels"],
},
)
tool = CorpusBuilder()
assert tool.get_status() == ToolStatus.DEGRADED
info = tool.get_info()
assert info["source_provider_summary"]["configured"] == 1
assert info["source_provider_summary"]["total"] == 2
assert {entry["name"] for entry in info["source_provider_menu"]} == {
"pexels",
"archive_org",
}
def test_corpus_builder_rejects_unavailable_pinned_sources(monkeypatch, tmp_path):
import tools.video.stock_sources as stock_sources
sources = {
"pexels": _DummySource("pexels", False),
"archive_org": _DummySource("archive_org", True),
}
monkeypatch.setattr(stock_sources, "all_sources", lambda: list(sources.values()))
monkeypatch.setattr(
stock_sources,
"available_sources",
lambda: [sources["archive_org"]],
)
monkeypatch.setattr(stock_sources, "get_source", lambda name: sources[name])
monkeypatch.setattr(
stock_sources,
"source_summary",
lambda: {
"configured": 1,
"total": 2,
"available_source_names": ["archive_org"],
"unavailable_source_names": ["pexels"],
},
)
result = CorpusBuilder().execute({
"corpus_dir": str(tmp_path / "corpus"),
"queries": [{"query": "rain at night"}],
"sources": ["pexels"],
})
assert not result.success
assert "Requested stock sources are unavailable" in result.error
assert "archive_org" in result.error
def test_documentary_renderer_family_maps_to_remotion():
assert VideoCompose._get_composition_id("documentary-montage") == "CinematicRenderer"
def test_provider_menu_preserves_tool_discovery_metadata(monkeypatch):
import tools.video.stock_sources as stock_sources
monkeypatch.setattr(stock_sources, "all_sources", lambda: [_DummySource("archive_org", True)])
monkeypatch.setattr(stock_sources, "available_sources", lambda: [_DummySource("archive_org", True)])
monkeypatch.setattr(
stock_sources,
"source_catalog",
lambda: [{"name": "archive_org", "status": "available"}],
)
monkeypatch.setattr(
stock_sources,
"source_summary",
lambda: {
"configured": 1,
"total": 1,
"available_source_names": ["archive_org"],
"unavailable_source_names": [],
},
)
registry = ToolRegistry()
registry.register(CorpusBuilder())
menu = registry.provider_menu()
entry = menu["corpus_population"]["available"][0]
assert entry["name"] == "corpus_builder"
assert entry["source_provider_summary"]["configured"] == 1
assert entry["source_provider_menu"][0]["name"] == "archive_org"
+29
View File
@@ -0,0 +1,29 @@
from tools.video.stock_sources import all_sources
from tools.video.stock_sources.unsplash import _build_download_url, _orientation_for_unsplash
from tools.video.stock_sources.wikimedia import _build_search_query, _kind_from_mime, _meta_value
def test_stock_source_autodiscovery_includes_new_sources():
names = {source.name for source in all_sources()}
assert "wikimedia" in names
assert "unsplash" in names
def test_wikimedia_search_query_respects_kind():
assert _build_search_query("rain city", "video").startswith("filetype:video")
assert _build_search_query("rain city", "image").startswith("filetype:image")
assert _build_search_query("rain city", "any") == "rain city"
def test_wikimedia_kind_and_metadata_helpers():
assert _kind_from_mime("video/webm", "File:foo.webm") == "video"
assert _kind_from_mime("image/jpeg", "File:foo.jpg") == "image"
assert _meta_value({"Artist": {"value": "<a href='/wiki/User:Test'>Test User</a>"}}, "Artist") == "Test User"
def test_unsplash_helpers_preserve_query_params():
assert _orientation_for_unsplash("square") == "squarish"
url = _build_download_url("https://images.unsplash.com/photo-123?ixid=abc", 1920)
assert "ixid=abc" in url
assert "w=1920" in url
assert "fm=jpg" in url
+10
View File
@@ -225,6 +225,7 @@ class ToolRegistry:
if cap not in menu:
menu[cap] = {"available": [], "unavailable": [], "total": 0, "configured": 0}
info = tool.get_info()
status = tool.get_status()
entry = {
"name": tool.name,
@@ -234,6 +235,15 @@ class ToolRegistry:
"install_instructions": tool.install_instructions,
"status": status.value,
}
for extra_key in (
"source_provider_menu",
"source_provider_summary",
"render_engines",
"remotion_note",
"provider_matrix",
):
if extra_key in info:
entry[extra_key] = info[extra_key]
if status == ToolStatus.AVAILABLE:
menu[cap]["available"].append(entry)
+56 -9
View File
@@ -94,7 +94,8 @@ class CorpusBuilder(BaseTool):
"pip install opencv-python numpy requests pillow transformers torch\n"
"At least one stock source must be configured:\n"
" PEXELS_API_KEY for Pexels (free at https://www.pexels.com/api/)\n"
" archive.org and nasa work without API keys"
" UNSPLASH_ACCESS_KEY for Unsplash (see https://unsplash.com/documentation)\n"
" archive.org, nasa, and wikimedia work without API keys"
)
agent_skills = []
@@ -201,10 +202,32 @@ class CorpusBuilder(BaseTool):
def get_status(self) -> ToolStatus:
try:
from tools.video.stock_sources import available_sources
from tools.video.stock_sources import all_sources, available_sources
except Exception:
return ToolStatus.UNAVAILABLE
return ToolStatus.AVAILABLE if available_sources() else ToolStatus.UNAVAILABLE
total = len(all_sources())
available = len(available_sources())
if available == 0:
return ToolStatus.UNAVAILABLE
if available < total:
return ToolStatus.DEGRADED
return ToolStatus.AVAILABLE
def get_info(self) -> dict[str, Any]:
info = super().get_info()
try:
from tools.video.stock_sources import source_catalog, source_summary
info["source_provider_menu"] = source_catalog()
info["source_provider_summary"] = source_summary()
except Exception:
info["source_provider_menu"] = []
info["source_provider_summary"] = {
"configured": 0,
"total": 0,
"available_source_names": [],
"unavailable_source_names": [],
}
return info
def estimate_cost(self, inputs: dict[str, Any]) -> float:
return 0.0 # all sources are free-tier
@@ -219,8 +242,10 @@ class CorpusBuilder(BaseTool):
from lib.corpus import Corpus
from tools.video.stock_sources import (
SearchFilters,
all_sources,
available_sources,
get_source,
source_summary,
)
corpus_dir = Path(inputs["corpus_dir"])
@@ -232,17 +257,36 @@ class CorpusBuilder(BaseTool):
thumbs_per_video = int(inputs.get("thumbs_per_video", 5))
# Resolve sources. If the caller passed an explicit list we
# respect it even if some are unavailable — their search
# calls will simply fail and be logged.
# must not silently degrade: pinned-but-unavailable sources
# are a provider substitution the agent needs to surface.
if source_names:
requested: list = []
unavailable_requested: list[str] = []
known_sources = {src.name: src for src in all_sources()}
for name in source_names:
try:
s = get_source(name)
except KeyError as e:
return ToolResult(success=False, error=str(e))
s = known_sources.get(name)
if s is None:
try:
s = get_source(name)
except KeyError as e:
return ToolResult(success=False, error=str(e))
if s.is_available():
requested.append(s)
else:
unavailable_requested.append(name)
if unavailable_requested:
summary = source_summary()
return ToolResult(
success=False,
error=(
"Requested stock sources are unavailable: "
f"{', '.join(unavailable_requested)}. "
"Available now: "
f"{', '.join(summary['available_source_names']) or 'none'}. "
"Check corpus_builder.source_provider_menu during preflight "
"before rerunning."
),
)
sources = requested
else:
sources = available_sources()
@@ -344,6 +388,9 @@ class CorpusBuilder(BaseTool):
"per_source_counts": per_source_counts,
"added_ids": added_ids,
"total_corpus_size": len(corp),
"requested_sources": source_names or [],
"resolved_sources": [s.name for s in sources],
"source_provider_summary": source_summary(),
"errors": errors[:25], # cap log noise
},
cost_usd=0.0,
+83 -24
View File
@@ -5,44 +5,69 @@ NASA, ...) used by `corpus_builder` to populate the local clip corpus.
See `base.py` for the protocol contract and the "adding a new source"
checklist.
Registry
--------
`all_sources()` returns one instance of every adapter known to the
package, in a stable order. `available_sources()` filters to the ones
whose `is_available()` returns True right now. The corpus builder
queries the latter during fan-out; it never imports adapters directly.
Discovery
---------
Adapters are auto-discovered from this package. `all_sources()` returns
one instance of every concrete adapter class found under
`tools.video.stock_sources`, ordered by each class's optional
`priority` and then by name. `available_sources()` filters to the ones
whose `is_available()` returns True right now.
To register a new adapter: add its class to the `_REGISTRY` tuple
below. Order matters only as a tiebreak when two sources return
matching clips.
This keeps source discoverability aligned with the rest of the repo:
adding a new adapter file is enough to make it visible to
`corpus_builder` and to preflight metadata.
"""
from __future__ import annotations
from .archive_org import ArchiveOrgSource
import importlib
import inspect
import pkgutil
from .base import Candidate, SearchFilters, StockSource
from .nasa import NasaSource
from .pexels import PexelsSource
__all__ = [
"Candidate",
"SearchFilters",
"StockSource",
"PexelsSource",
"ArchiveOrgSource",
"NasaSource",
"all_sources",
"available_sources",
"get_source",
"source_catalog",
"source_summary",
]
# Explicit, ordered list of every adapter class the package exposes.
# The corpus builder iterates this (filtered by availability) during
# fan-out. Order matters only as a tiebreak for identical clip ids.
_REGISTRY: tuple[type, ...] = (
PexelsSource,
ArchiveOrgSource,
NasaSource,
)
def _is_source_adapter_class(cls: type) -> bool:
"""Return True for concrete source adapters in this package."""
return (
inspect.isclass(cls)
and cls.__module__.startswith(f"{__name__}.")
and cls.__module__ != f"{__name__}.base"
and isinstance(getattr(cls, "name", None), str)
and bool(getattr(cls, "name", None))
and callable(getattr(cls, "is_available", None))
and callable(getattr(cls, "search", None))
and callable(getattr(cls, "download", None))
)
def _source_classes() -> list[type]:
"""Auto-discover stock source classes under this package."""
discovered: dict[str, type] = {}
for module_info in pkgutil.iter_modules(__path__, f"{__name__}."):
if module_info.ispkg or module_info.name.endswith(".base"):
continue
module = importlib.import_module(module_info.name)
for _, cls in inspect.getmembers(module, inspect.isclass):
if not _is_source_adapter_class(cls):
continue
discovered[getattr(cls, "name")] = cls
return sorted(
discovered.values(),
key=lambda cls: (
int(getattr(cls, "priority", 100)),
getattr(cls, "display_name", getattr(cls, "name")).lower(),
),
)
def all_sources() -> list[StockSource]:
@@ -53,7 +78,7 @@ def all_sources() -> list[StockSource]:
want to show the user what sources exist regardless of whether
their credentials are configured.
"""
return [cls() for cls in _REGISTRY]
return [cls() for cls in _source_classes()]
def available_sources() -> list[StockSource]:
@@ -67,6 +92,40 @@ def available_sources() -> list[StockSource]:
return [s for s in all_sources() if s.is_available()]
def source_catalog() -> list[dict[str, object]]:
"""Return discoverability metadata for every stock source."""
catalog: list[dict[str, object]] = []
for source in all_sources():
cls = source.__class__
available = bool(source.is_available())
catalog.append({
"name": source.name,
"display_name": getattr(cls, "display_name", source.name),
"provider": getattr(cls, "provider", source.name),
"status": "available" if available else "unavailable",
"install_instructions": getattr(
cls,
"install_instructions",
"See the source adapter docs for setup details.",
),
"supports": getattr(cls, "supports", {}),
})
return catalog
def source_summary() -> dict[str, object]:
"""Summarize source availability for preflight and tool contracts."""
catalog = source_catalog()
available = [entry["name"] for entry in catalog if entry["status"] == "available"]
unavailable = [entry["name"] for entry in catalog if entry["status"] != "available"]
return {
"configured": len(available),
"total": len(catalog),
"available_source_names": available,
"unavailable_source_names": unavailable,
}
def get_source(name: str) -> StockSource:
"""Look up a single adapter by its `name` attribute.
+7
View File
@@ -70,6 +70,13 @@ class ArchiveOrgSource:
"""
name = "archive_org"
display_name = "Archive.org"
provider = "archive_org"
priority = 20
install_instructions = (
"No setup required. Archive.org is available without API keys."
)
supports = {"video": True, "image": False}
def is_available(self) -> bool:
# No API key, no config. As long as the network is up, we're
+3 -2
View File
@@ -29,8 +29,9 @@ Adding a new source
field the corpus might later want to display or attribute (creator,
licence, source URL, description/tags for the text channel of the
CLIP fused ranking).
3. Register the class so `corpus_builder` can discover it. The
registration pattern lives alongside the first concrete adapter.
3. Give the class a stable `name` attribute and optional discoverability
metadata such as `display_name`, `install_instructions`, `supports`,
and `priority`. The package auto-discovers concrete adapters.
"""
from __future__ import annotations
+8
View File
@@ -55,6 +55,14 @@ class NasaSource:
"""
name = "nasa"
display_name = "NASA"
provider = "nasa"
priority = 30
install_instructions = (
"No setup required. NASA media search works without an API key; "
"NASA_API_KEY is optional for higher rate limits."
)
supports = {"video": True, "image": True}
def is_available(self) -> bool:
# The public API is unauthenticated; as long as the network is
+8
View File
@@ -36,6 +36,14 @@ class PexelsSource:
"""
name = "pexels"
display_name = "Pexels"
provider = "pexels"
priority = 10
install_instructions = (
"Set PEXELS_API_KEY in .env to enable Pexels stock search "
"(free key at https://www.pexels.com/api/)."
)
supports = {"video": True, "image": True}
def is_available(self) -> bool:
return bool(os.environ.get("PEXELS_API_KEY"))
+184
View File
@@ -0,0 +1,184 @@
"""Unsplash stock photo adapter.
Unsplash is image-only in this pipeline. It widens the corpus for
modern, polished, lifestyle, and product-adjacent scenes where a
high-quality still can still be valuable to the edit.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from .base import Candidate, SearchFilters
_SEARCH_URL = "https://api.unsplash.com/search/photos"
_UNSPLASH_LICENSE = "Unsplash License (use returned hotlinked image URLs)"
_USER_AGENT = "OpenMontageBot/0.1 (https://github.com/calesthio/OpenMontage)"
class UnsplashSource:
"""Adapter for Unsplash photo search."""
name = "unsplash"
display_name = "Unsplash"
provider = "unsplash"
priority = 18
install_instructions = (
"Set UNSPLASH_ACCESS_KEY in .env to enable Unsplash image search "
"(see https://unsplash.com/documentation)."
)
supports = {"video": False, "image": True}
def is_available(self) -> bool:
return bool(os.environ.get("UNSPLASH_ACCESS_KEY"))
def search(self, query: str, filters: SearchFilters) -> list[Candidate]:
import requests # lazy
kind = (filters.kind or "video").lower()
if kind == "video":
return []
params: dict[str, Any] = {
"query": query,
"page": max(1, filters.page),
"per_page": max(1, min(filters.per_page, 30)),
"content_filter": "high",
}
orientation = _orientation_for_unsplash(filters.orientation)
if orientation:
params["orientation"] = orientation
r = requests.get(
_SEARCH_URL,
params=params,
headers=self._headers(),
timeout=30,
)
r.raise_for_status()
data = r.json()
results = data.get("results") or []
out: list[Candidate] = []
for photo in results:
cand = _photo_to_candidate(photo, filters)
if cand is not None:
out.append(cand)
return out
def download(self, candidate: Candidate, out_path: Path) -> Path:
import requests # lazy
if not candidate.download_url:
raise ValueError(f"Candidate {candidate.clip_id} has no download_url")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with requests.get(
candidate.download_url,
stream=True,
timeout=180,
headers={"User-Agent": _USER_AGENT},
) as r:
r.raise_for_status()
with open(out_path, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 16):
if chunk:
f.write(chunk)
return out_path
def _headers(self) -> dict[str, str]:
key = os.environ.get("UNSPLASH_ACCESS_KEY")
if not key:
raise RuntimeError(
"UNSPLASH_ACCESS_KEY not set. Create an app at "
"https://unsplash.com/documentation and add the access key to .env."
)
return {
"Authorization": f"Client-ID {key}",
"Accept-Version": "v1",
"User-Agent": _USER_AGENT,
}
def _photo_to_candidate(photo: dict[str, Any], filters: SearchFilters) -> Candidate | None:
width = int(photo.get("width") or 0)
height = int(photo.get("height") or 0)
if filters.min_width is not None and width and width < filters.min_width:
return None
if filters.orientation and not _matches_orientation(filters.orientation, width, height):
return None
user = photo.get("user") or {}
links = photo.get("links") or {}
urls = photo.get("urls") or {}
raw_url = urls.get("raw") or urls.get("regular") or ""
if not raw_url:
return None
description_parts = [
photo.get("description") or "",
photo.get("alt_description") or "",
photo.get("slug") or "",
]
source_tags = " ".join(part.strip() for part in description_parts if part).strip()
if len(source_tags) > 500:
source_tags = source_tags[:500]
return Candidate(
source=UnsplashSource.name,
source_id=str(photo.get("id") or ""),
source_url=links.get("html", "") or "",
download_url=_build_download_url(raw_url, target_width=max(filters.min_width or 0, 1920)),
kind="image",
width=width,
height=height,
duration=0.0,
creator=user.get("name", "") or "",
license=_UNSPLASH_LICENSE,
source_tags=source_tags,
thumbnail_url=urls.get("small", "") or urls.get("thumb", "") or raw_url,
extra={
"color": photo.get("color"),
"blur_hash": photo.get("blur_hash"),
"download_location": links.get("download_location"),
"photographer_url": user.get("links", {}).get("html"),
},
)
def _orientation_for_unsplash(orientation: str | None) -> str | None:
if orientation == "landscape":
return "landscape"
if orientation == "portrait":
return "portrait"
if orientation == "square":
return "squarish"
return None
def _matches_orientation(orientation: str, width: int, height: int) -> bool:
if not width or not height:
return True
if orientation == "landscape":
return width >= height
if orientation == "portrait":
return height > width
if orientation == "square":
return width == height
return True
def _build_download_url(raw_url: str, target_width: int) -> str:
parts = urlparse(raw_url)
params = dict(parse_qsl(parts.query, keep_blank_values=True))
params.setdefault("fm", "jpg")
params.setdefault("q", "80")
if target_width > 0:
params["w"] = str(target_width)
params.setdefault("fit", "max")
return urlunparse(parts._replace(query=urlencode(params)))
+196
View File
@@ -0,0 +1,196 @@
"""Wikimedia Commons stock media adapter.
Provides image and video search over Wikimedia Commons using the
MediaWiki API. Commons is a uniquely useful documentary source because
it mixes public-domain historical imagery, recent CC-licensed videos,
and educational media under one searchable catalogue.
"""
from __future__ import annotations
import html
import re
from pathlib import Path
from typing import Any
from .base import Candidate, SearchFilters
_API_URL = "https://commons.wikimedia.org/w/api.php"
_USER_AGENT = "OpenMontageBot/0.1 (https://github.com/calesthio/OpenMontage)"
_COMMONS_LICENSE = "Wikimedia Commons (verify per-file license)"
_HTML_TAG_RE = re.compile(r"<[^>]+>")
class WikimediaSource:
"""Adapter for Wikimedia Commons media search."""
name = "wikimedia"
display_name = "Wikimedia Commons"
provider = "wikimedia"
priority = 25
install_instructions = (
"No setup required. Wikimedia Commons media search works without API keys."
)
supports = {"video": True, "image": True}
def is_available(self) -> bool:
return True
def search(self, query: str, filters: SearchFilters) -> list[Candidate]:
import requests # lazy
params = {
"action": "query",
"format": "json",
"generator": "search",
"gsrsearch": _build_search_query(query, filters.kind),
"gsrnamespace": 6,
"gsrlimit": max(1, min(filters.per_page, 50)),
"gsroffset": max(0, (max(filters.page, 1) - 1) * max(1, min(filters.per_page, 50))),
"prop": "imageinfo|info",
"iiprop": "url|size|mime|extmetadata|mediatype",
"iiurlwidth": 640,
"inprop": "url",
}
r = requests.get(
_API_URL,
params=params,
headers={"User-Agent": _USER_AGENT},
timeout=30,
)
r.raise_for_status()
data = r.json()
pages = list(((data.get("query") or {}).get("pages") or {}).values())
pages.sort(key=lambda page: int(page.get("index", 0)))
out: list[Candidate] = []
for page in pages:
cand = _page_to_candidate(page, filters)
if cand is not None:
out.append(cand)
return out
def download(self, candidate: Candidate, out_path: Path) -> Path:
import requests # lazy
if not candidate.download_url:
raise ValueError(f"Candidate {candidate.clip_id} has no download_url")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with requests.get(
candidate.download_url,
stream=True,
timeout=300,
headers={"User-Agent": _USER_AGENT},
) as r:
r.raise_for_status()
with open(out_path, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 16):
if chunk:
f.write(chunk)
return out_path
def _build_search_query(query: str, kind: str) -> str:
user_query = query.strip()
kind = (kind or "video").lower()
if kind == "video":
return f"filetype:video {user_query}".strip()
if kind == "image":
return f"filetype:image {user_query}".strip()
return user_query
def _page_to_candidate(page: dict[str, Any], filters: SearchFilters) -> Candidate | None:
infos = page.get("imageinfo") or []
if not infos:
return None
info = infos[0]
mime = (info.get("mime") or "").lower()
kind = _kind_from_mime(mime, page.get("title", ""))
requested_kind = (filters.kind or "video").lower()
if requested_kind == "video" and kind != "video":
return None
if requested_kind == "image" and kind != "image":
return None
width = int(info.get("width") or 0)
height = int(info.get("height") or 0)
duration = float(info.get("duration") or 0.0)
if filters.min_width is not None and width and width < filters.min_width:
return None
if filters.min_duration is not None and duration and duration < filters.min_duration:
return None
if filters.max_duration is not None and duration and duration > filters.max_duration:
return None
if filters.orientation and not _matches_orientation(filters.orientation, width, height):
return None
meta = info.get("extmetadata") or {}
object_name = _meta_value(meta, "ObjectName")
description = _meta_value(meta, "ImageDescription")
categories = _meta_value(meta, "Categories")
creator = _meta_value(meta, "Artist")
license_name = _meta_value(meta, "LicenseShortName")
usage_terms = _meta_value(meta, "UsageTerms")
source_tags = " ".join(part for part in (object_name, description, categories) if part).strip()
if len(source_tags) > 500:
source_tags = source_tags[:500]
title = page.get("title", "")
page_id = str(page.get("pageid") or title.replace("File:", "", 1))
source_url = info.get("descriptionurl") or page.get("canonicalurl") or ""
return Candidate(
source=WikimediaSource.name,
source_id=page_id,
source_url=source_url,
download_url=info.get("url", "") or "",
kind=kind,
width=width,
height=height,
duration=duration,
creator=creator,
license=license_name or usage_terms or _COMMONS_LICENSE,
source_tags=source_tags,
thumbnail_url=info.get("thumburl", "") or info.get("url", "") or "",
extra={
"mime": mime,
"title": title,
"mediatype": info.get("mediatype"),
"descriptionshorturl": info.get("descriptionshorturl"),
},
)
def _kind_from_mime(mime: str, title: str) -> str:
if mime.startswith("video/") or title.lower().endswith((".webm", ".ogv", ".ogg")):
return "video"
return "image"
def _matches_orientation(orientation: str, width: int, height: int) -> bool:
if not width or not height:
return True
if orientation == "landscape":
return width >= height
if orientation == "portrait":
return height > width
if orientation == "square":
return width == height
return True
def _meta_value(meta: dict[str, Any], key: str) -> str:
raw = ((meta.get(key) or {}).get("value")) or ""
if not raw:
return ""
text = html.unescape(str(raw))
text = _HTML_TAG_RE.sub(" ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
+6 -6
View File
@@ -554,6 +554,7 @@ class VideoCompose(BaseTool):
"explainer-data": "Explainer",
"explainer-teacher": "Explainer",
"cinematic-trailer": "CinematicRenderer",
"documentary-montage": "CinematicRenderer",
"product-reveal": "Explainer",
"screen-demo": "Explainer",
"presenter": "TalkingHead",
@@ -691,12 +692,11 @@ class VideoCompose(BaseTool):
component types, transitions, and mixed content all in a single
React-based render pass.
Returns False (i.e. use FFmpeg) ONLY when ALL of these are true:
1. Every cut source is a video file (no images, no component types)
2. No cut requests animations or transitions
3. No cut uses a Remotion scene type
4. The edit_decisions explicitly set renderer_family to "ffmpeg-only"
OR Remotion is not available
Returns False (i.e. use FFmpeg) only when Remotion is not
available. For `operation="render"` the governance default is
Remotion-first: the renderer family was chosen earlier, and the
tool should preserve that decision instead of silently
downgrading to FFmpeg.
This "Remotion-first" policy means mixed content (video clips +
animated stills + text cards) is always composed in Remotion, which