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:
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)}
|
||||
|
||||
Reference in new issue
Block a user