Webapp: video ref pick from library (#1775)

- The "+" button on the Video Ref row now shows options on hover:
**Upload** or **Pick from library** (on mobile it opens an "Add video"
sheet)
- Picking from library opens the gallery in select mode, filtered to
videos only
- Library videos respect the same limits as uploads (max count and total
duration, with the same error toasts)
- Reused the existing add-button component from the image row so all
reference rows look and behave the same
This commit is contained in:
BFlat authored and GitHub committed 2026-07-12 09:12:14 +07:00
1 parent 9cc9d4b9cf
commit bc0174ac7a
6 files changed
+122 -73

No files matched your search

@@ -413,9 +413,11 @@ const ADD_BUTTON_CLASS =
export const AddButton = ({
onUpload,
onPickFromLibrary,
title = "Add image",
}: {
onUpload: () => void;
onPickFromLibrary?: () => void;
title?: string;
}) => {
const isMobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -444,7 +446,7 @@ export const AddButton = ({
<SettingsDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
title="Add image"
title={title}
// Non-modal: this drawer opens the library modal on top of itself.
// Two overlapping modal Radix layers each lock <body>, and the
// sheet's close/cleanup strands a `pointer-events: none` lock on
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faMusic,
faPlay,
faPlus,
faSpinnerThird,
faStop,
faVideo,
@@ -12,6 +11,7 @@ import {
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import { toast } from "../toast/toast";
import { AddButton } from "./ImagePromptRow";
import type { RefVideo, RefAudio } from "./types";
import {
uploadVideo,
@@ -27,6 +27,7 @@ interface MediaReferenceRowProps {
onReferenceVideosChange: (videos: RefVideo[]) => void;
maxVideoCount: number;
maxVideoRefDuration: number;
onPickVideoFromLibrary?: () => void;
referenceAudios: RefAudio[];
onReferenceAudiosChange: (audios: RefAudio[]) => void;
maxAudioCount: number;
@@ -41,6 +42,7 @@ export const MediaReferenceRow = ({
onReferenceVideosChange,
maxVideoCount,
maxVideoRefDuration,
onPickVideoFromLibrary,
referenceAudios,
onReferenceAudiosChange,
maxAudioCount,
@@ -251,15 +253,11 @@ export const MediaReferenceRow = ({
</div>
)}
{canAddVideo && (
<button
onClick={() => videoInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
<AddButton
onUpload={() => videoInputRef.current?.click()}
onPickFromLibrary={onPickVideoFromLibrary}
title="Add video"
/>
)}
</div>
</div>
@@ -305,15 +303,7 @@ export const MediaReferenceRow = ({
</div>
)}
{canAddAudio && (
<button
onClick={() => audioInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
<AddButton onUpload={() => audioInputRef.current?.click()} />
)}
</div>
</div>
@@ -5,6 +5,7 @@ export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
export type { StoredCharacter } from "./characters-store";
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
export { getVideoDurationFromUrl } from "./upload-media";
export {
MobilePromptForm,
MobileSelectField,
@@ -73,21 +73,21 @@ export const uploadAudio: UploadMediaFn = async ({
}
};
export const getVideoDuration = (file: File): Promise<number> =>
// Resolves 0 when metadata can't be loaded.
export const getVideoDurationFromUrl = (url: string): Promise<number> =>
new Promise((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
URL.revokeObjectURL(video.src);
resolve(Math.round(video.duration));
};
video.onerror = () => {
URL.revokeObjectURL(video.src);
resolve(0);
};
video.src = URL.createObjectURL(file);
video.onloadedmetadata = () => resolve(Math.round(video.duration));
video.onerror = () => resolve(0);
video.src = url;
});
export const getVideoDuration = (file: File): Promise<number> => {
const url = URL.createObjectURL(file);
return getVideoDurationFromUrl(url).finally(() => URL.revokeObjectURL(url));
};
export const getAudioDuration = (file: File): Promise<number> =>
new Promise((resolve) => {
const audio = document.createElement("audio");
@@ -427,7 +427,7 @@ const SidebarMenu = React.forwardRef<
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
{...props}
/>
));
@@ -27,6 +27,7 @@ import {
SettingsDrawer,
DrawerOptionList,
DrawerSection,
getVideoDurationFromUrl,
type RefImage,
type RefVideo,
type RefAudio,
@@ -365,12 +366,16 @@ export default function CreateVideo() {
);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [isEndFramePickerOpen, setIsEndFramePickerOpen] = useState(false);
const [isVideoRefPickerOpen, setIsVideoRefPickerOpen] = useState(false);
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
const [isOutputDrawerOpen, setIsOutputDrawerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
const [endFramePickerSelectedIds, setEndFramePickerSelectedIds] = useState<
string[]
>([]);
const [videoRefPickerSelectedIds, setVideoRefPickerSelectedIds] = useState<
string[]
>([]);
useEffect(() => {
if (isImagePickerOpen) setPickerSelectedIds([]);
@@ -380,6 +385,10 @@ export default function CreateVideo() {
if (isEndFramePickerOpen) setEndFramePickerSelectedIds([]);
}, [isEndFramePickerOpen]);
useEffect(() => {
if (isVideoRefPickerOpen) setVideoRefPickerSelectedIds([]);
}, [isVideoRefPickerOpen]);
// Characters store for @-mentions
const storedCharacters = useCharactersStore((s) => s.characters);
const charactersLoaded = useCharactersStore((s) => s.loaded);
@@ -428,6 +437,9 @@ export default function CreateVideo() {
!!selectedModel?.image_references_supported;
const supportsVideoRefs = !!selectedModel?.video_references_supported;
const supportsAudioRefs = !!selectedModel?.audio_references_supported;
const maxVideoRefs = selectedModel?.video_references_max ?? 3;
const maxVideoRefDuration =
selectedModel?.video_references_max_total_duration_seconds ?? 30;
const supportsRefMode =
!!selectedModel?.image_references_supported ||
supportsVideoRefs ||
@@ -858,6 +870,59 @@ export default function CreateVideo() {
[referenceImages, isReferenceMode, selectedModel],
);
const videoRefPickerMax = Math.max(1, maxVideoRefs - referenceVideos.length);
const handleVideoRefPickerSelect = useCallback(
(id: string) => {
setVideoRefPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= videoRefPickerMax) {
return videoRefPickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[videoRefPickerMax],
);
const handleLibraryVideoSelect = useCallback(
async (items: GalleryItem[]) => {
setIsVideoRefPickerOpen(false);
const availableSlots = Math.max(0, maxVideoRefs - referenceVideos.length);
const picked = items.slice(0, availableSlots);
const added: RefVideo[] = [];
let total = referenceVideos.reduce((sum, v) => sum + v.duration, 0);
for (const item of picked) {
const url = item.fullImage;
if (!url) continue;
const duration = await getVideoDurationFromUrl(url);
if (duration <= 0) {
toast.error("Could not read video file");
continue;
}
if (total + duration > maxVideoRefDuration) {
toast.error(
`Video too long — max ${maxVideoRefDuration}s total (${maxVideoRefDuration - total}s remaining)`,
);
continue;
}
total += duration;
added.push({
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-video"),
mediaToken: item.id,
duration,
});
}
if (added.length > 0) {
setReferenceVideos([...referenceVideos, ...added]);
}
},
[referenceVideos, maxVideoRefs, maxVideoRefDuration, setReferenceVideos],
);
const handleEndFrameLibrarySelect = useCallback((items: GalleryItem[]) => {
const item = items[0];
if (!item) return;
@@ -1079,6 +1144,26 @@ export default function CreateVideo() {
dismissBatch,
]);
// Video/audio reference row, shared by the mobile and desktop forms.
const mediaReferenceRow =
isReferenceMode && (supportsVideoRefs || supportsAudioRefs) ? (
<MediaReferenceRow
videoSupported={supportsVideoRefs}
audioSupported={supportsAudioRefs}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={maxVideoRefs}
maxVideoRefDuration={maxVideoRefDuration}
onPickVideoFromLibrary={() => setIsVideoRefPickerOpen(true)}
referenceAudios={referenceAudios}
onReferenceAudiosChange={setReferenceAudios}
maxAudioCount={selectedModel?.audio_references_max ?? 2}
maxAudioRefDuration={
selectedModel?.audio_references_max_total_duration_seconds ?? 30
}
/>
) : undefined;
// ── Mobile form ───────────────────────────────────────────────────────
const activeResolutionLabel = resolutionItems?.find((i) => i.selected)?.label;
@@ -1172,26 +1257,7 @@ export default function CreateVideo() {
/>
) : undefined
}
mediaRefs={
isReferenceMode && (supportsVideoRefs || supportsAudioRefs) ? (
<MediaReferenceRow
videoSupported={supportsVideoRefs}
audioSupported={supportsAudioRefs}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={selectedModel?.video_references_max ?? 3}
maxVideoRefDuration={
selectedModel?.video_references_max_total_duration_seconds ?? 30
}
referenceAudios={referenceAudios}
onReferenceAudiosChange={setReferenceAudios}
maxAudioCount={selectedModel?.audio_references_max ?? 2}
maxAudioRefDuration={
selectedModel?.audio_references_max_total_duration_seconds ?? 30
}
/>
) : undefined
}
mediaRefs={mediaReferenceRow}
settingsFields={
<>
{hasSizeOptions && (
@@ -1438,28 +1504,7 @@ export default function CreateVideo() {
})
}
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
mediaReferenceRow={
isReferenceMode && (supportsVideoRefs || supportsAudioRefs) ? (
<MediaReferenceRow
videoSupported={supportsVideoRefs}
audioSupported={supportsAudioRefs}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={selectedModel?.video_references_max ?? 3}
maxVideoRefDuration={
selectedModel?.video_references_max_total_duration_seconds ??
30
}
referenceAudios={referenceAudios}
onReferenceAudiosChange={setReferenceAudios}
maxAudioCount={selectedModel?.audio_references_max ?? 2}
maxAudioRefDuration={
selectedModel?.audio_references_max_total_duration_seconds ??
30
}
/>
) : undefined
}
mediaReferenceRow={mediaReferenceRow}
rightToolbar={
<GenerationCountPicker
batchSizeMax={selectedModel?.batch_size_max ?? 4}
@@ -1637,6 +1682,17 @@ export default function CreateVideo() {
forceFilter="image"
hideFilter
/>
<GalleryModal
mode="select"
isOpen={isVideoRefPickerOpen}
onClose={() => setIsVideoRefPickerOpen(false)}
selectedItemIds={videoRefPickerSelectedIds}
onSelectItem={handleVideoRefPickerSelect}
maxSelections={videoRefPickerMax}
onUseSelected={handleLibraryVideoSelect}
forceFilter="video"
hideFilter
/>
<CharactersModal
isOpen={isCharactersModalOpen}
onClose={() => setIsCharactersModalOpen(false)}