import { useEffect, useState, type ReactNode } from "react"; import { videoEditApi, type StockClip } from "../../api/endpoints"; import type { VideoAsset, VideoScene } from "../../api/types"; import { CheckIcon } from "../icons/Icons"; import { errorMessage, Loading, Modal } from "../ui"; import type { PanelProps } from "./EditorPanels"; import { withoutMedia } from "./sceneCode"; /** The scene media modals, like blog2video's editor: pick an existing image or clip, upload, search stock footage, * and write / review an AI image. A scene has one visual slot, so whatever is picked replaces the current one. */ export const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"]; export const IMAGE_MAX = 5 * 1024 * 1024; // blog2video's limit for a scene image const ListIcon = () => ( ); const XIcon = () => ( ); const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`; /** Two big choices: reuse something this video already has, or bring in a new one. */ export function ChoiceModal({ title, subtitle, existing, fresh, freshIcon, onPick, onClose }: { title: string; subtitle: string; existing: string; fresh: string; freshIcon: ReactNode; onPick: (choice: "existing" | "new") => void; onClose: () => void; }) { return (

{subtitle}

); } /** The video's own images or clips; the picked one goes on this scene (the same one can sit on several scenes). */ export function AssetPickerModal(p: PanelProps & { scene: VideoScene; kind: "image" | "video"; onNew: () => void; onClose: () => void }) { const assets: VideoAsset[] = (p.project.assets ?? []).filter((a) => a.asset_type === p.kind && !a.excluded && a.r2_url); const onScene = new Set((p.scene.images ?? []).map((i) => i.filename)); const [picked, setPicked] = useState(null); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); const noun = p.kind === "image" ? "image" : "clip"; async function use() { if (!picked) return; setBusy(true); setFailed(false); // A still is assigned by its asset id, a clip by its file name. const ok = await p.act(() => (p.kind === "image" ? videoEditApi.assignImage(p.id, p.scene.id, picked.id) : videoEditApi.linkStock(p.id, p.scene.id, picked.filename))); setBusy(false); if (ok) p.onClose(); else setFailed(true); } return ( undefined : p.onClose}>
{assets.length === 0 ? (

This video has no {p.kind === "image" ? "images" : "stock clips"} yet.

) : ( <>

Replaces this scene's current image or clip.

{assets.map((a) => { const on = picked?.id === a.id; return ( ); })}
)} {failed &&

Couldn't add it. Try again.

}
); } /** Search the stock libraries; the picked clip is downloaded into the video and put on this scene. */ export function StockSearchModal(p: PanelProps & { scene: VideoScene; onClose: () => void }) { const [query, setQuery] = useState(p.scene.title ?? ""); const [clips, setClips] = useState(null); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [picked, setPicked] = useState(null); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); async function search() { if (!query.trim()) return; setSearching(true); setSearchError(null); setPicked(null); try { setClips((await videoEditApi.searchStock(p.id, query.trim())).clips); } catch (e) { setSearchError(errorMessage(e)); } finally { setSearching(false); } } // The first search runs by itself, on the scene's title. useEffect(() => { search(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function use() { if (!picked) return; setBusy(true); setFailed(false); const ok = await p.act(async () => { // Downloading makes the clip an asset; linking puts it on this scene. const added = await videoEditApi.useStock(p.id, p.scene.id, picked); await videoEditApi.linkStock(p.id, p.scene.id, added.filename); }); setBusy(false); if (ok) p.onClose(); else setFailed(true); } return ( undefined : p.onClose}>

Clips are converted to 30 fps so they stay in sync with your video.

{/* The field takes the row; Search sits beside it on the right */}
setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && search()} />
{searching && } {searchError &&

{searchError}

} {!searching && clips?.length === 0 &&

No clips found. Try other words.

} {!searching && clips && clips.length > 0 && (
{clips.map((c) => { const on = picked === c; return ( ); })}
)} {failed &&

Couldn't add the clip. Try again.

}
Uses 1 AI edit
); } /** Take one image or clip off a scene. Stays open (and loading) until the scene is saved and reloaded. */ export function RemoveMediaModal(p: PanelProps & { scene: VideoScene; filename: string; kind: string; onClose: () => void }) { const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); async function remove() { setBusy(true); setFailed(false); const ok = await p.act(() => videoEditApi.updateScene(p.id, p.scene.id, { remotion_code: withoutMedia(p.scene, p.project.template, p.filename), })); setBusy(false); if (ok) p.onClose(); else setFailed(true); } return ( // While the removal runs, Escape, the backdrop and the close button do nothing. undefined : p.onClose}>

It comes off this scene only. It stays in the video's media, so you can add it back from Images/footage.

{failed &&

Couldn't remove it. Try again.

}
); } /** Delete an image or clip from the video for good (blog2video removes its files and takes it off any scene). */ export function DeleteAssetModal(p: PanelProps & { asset: { id: number; kind: string }; onClose: () => void }) { const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); const clip = p.asset.kind === "video"; async function remove() { setBusy(true); setFailed(false); const ok = await p.act(() => videoEditApi.deleteAsset(p.id, p.asset.id)); setBusy(false); if (ok) p.onClose(); else setFailed(true); } return ( undefined : p.onClose}>

This permanently deletes the {clip ? "stock clip" : "image"} from this video. It can't be recovered, and you'll need to add it again if you want it back.

{failed &&

Couldn't delete it. Try again.

}
); } /** The prompt for an AI image. Generating runs in the background (see SceneList), so this closes straight away. */ export function AiImageModal({ scene, aiEditsLeft, onGenerate, onClose }: { scene: VideoScene; aiEditsLeft: number | null; onGenerate: (prompt: string) => void; onClose: () => void; }) { const [prompt, setPrompt] = useState(""); const ok = prompt.trim().length >= 3; return (

Describe the image you want. Uses 1 AI edit{aiEditsLeft !== null ? ` ยท ${aiEditsLeft} left` : ""}.