Webapp: send library media to the create-image / create-video promptbox (#1807)

- Adds a **Send to prompt** action to the library's multi-select bar
with two
  targets: **Create image** and **Create video**
- Selected images are attached as reference images on the target page
without
touching the user's typed prompt or settings; selection is cleared and
the
  app navigates to the create page
- Selected videos can be sent to the video prompt as reference videos;
models
without video-reference support (only Seedance has it) get an
explanatory
  toast instead of a silent drop
- Media travels via `pendingRefImages` / `pendingRefVideos` store slots
(mirroring the existing `pendingRecreate` pattern) and is merged on the
  receiving page once its model list is loaded, so the **real per-model
reference caps** apply - fixes silent no-ops when refs were already
attached
- Merging dedupes by media token, clamps to the model cap, and toasts
partial
  adds ("Added 3 of 5 …"), full decks, and unsupported models
- Keyframe-only video models take the first sent image as the start
frame;
reference-capable models switch into reference mode so the deck is
visible
- DRY: shared `galleryItemToRefImage` mapper (reused by the create-image
library picker) and `buildVideoRefsToAdd` (reused by the video-ref
picker,
  duration probing + total-duration cap)
- Bar UX: option list adapts to the selection (video-only hides Create
image),
  icon-only button on phones to avoid overflowing the bar
This commit is contained in:
BFlat
2026-07-25 15:07:21 +07:00
committed by GitHub
parent 9fbbfb5877
commit b741cdd76c
6 changed files with 395 additions and 32 deletions
@@ -0,0 +1,137 @@
import type { NavigateFunction } from "react-router-dom";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import { toast } from "../components/toast/toast";
import type { RefImage, RefVideo } from "../components/prompt-box/types";
import { useCreateImageStore } from "../pages/create-image/create-image-store";
import { useCreateVideoStore } from "../pages/create-video/create-video-store";
export type PromptDestination = "image" | "video";
const DESTINATION_ROUTES: Record<PromptDestination, string> = {
image: "/create-image",
video: "/create-video",
};
export function isImagePromptable(item: GalleryItem): boolean {
return item.mediaClass === "image" && !!(item.thumbnail || item.fullImage);
}
// Videos can only be prompted from on the video page (as reference videos,
// model permitting).
export function isVideoPromptable(item: GalleryItem): boolean {
return item.mediaClass === "video" && !!item.fullImage;
}
// Parks library media for a create page's reference deck and navigates
// there. The reference caps are per-model and only known on the receiving
// page, so the media travels via the store's pending slots and the page
// merges it (dedupe + real caps) once its model list is loaded.
export function sendToPrompt(
items: GalleryItem[],
destination: PromptDestination,
navigate: NavigateFunction,
): void {
const images = items.filter(isImagePromptable);
const videos = destination === "video" ? items.filter(isVideoPromptable) : [];
if (images.length === 0 && videos.length === 0) {
toast.error("Select at least one image to use as a reference");
return;
}
if (destination === "image") {
useCreateImageStore
.getState()
.setPendingRefImages(images.map(galleryItemToRefImage));
if (items.some(isVideoPromptable)) {
toast.success(
`Sent ${images.length} ${images.length === 1 ? "image" : "images"} — videos can't be image references`,
);
}
} else {
const store = useCreateVideoStore.getState();
if (images.length > 0) {
store.setPendingRefImages(images.map(galleryItemToRefImage));
}
if (videos.length > 0) {
store.setPendingRefVideos(videos.map(galleryItemToRefVideo));
}
}
navigate(DESTINATION_ROUTES[destination]);
}
export function galleryItemToRefImage(item: GalleryItem): RefImage {
return {
id: crypto.randomUUID(),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
};
}
function galleryItemToRefVideo(item: GalleryItem): RefVideo {
return {
id: crypto.randomUUID(),
url: item.fullImage || "",
file: new File([], "library-video"),
mediaToken: item.id,
// 0 = unknown; the video page probes the file before adding it.
duration: item.durationMillis ? item.durationMillis / 1000 : 0,
};
}
// ── Merge (used by the receiving create pages) ─────────────────────────────
export interface MergeRefImagesResult {
next: RefImage[];
added: number;
// Incoming refs whose media token was already attached.
duplicates: number;
// Fresh refs that didn't fit under the model's cap.
overflow: number;
}
// Merges incoming reference images into an existing deck: skips refs whose
// media token is already attached, then clamps to the model's cap.
export function mergeRefImages(
existing: RefImage[],
incoming: RefImage[],
maxRefs: number,
): MergeRefImagesResult {
const existingTokens = new Set(
existing.map((ref) => ref.mediaToken).filter(Boolean),
);
const fresh = incoming.filter(
(ref) => !ref.mediaToken || !existingTokens.has(ref.mediaToken),
);
const slots = Math.max(0, maxRefs - existing.length);
const added = fresh.slice(0, slots);
return {
next: [...existing, ...added],
added: added.length,
duplicates: incoming.length - fresh.length,
overflow: fresh.length - added.length,
};
}
// Feedback for a merge. Silent when everything landed or the only skips were
// images already attached (either way, everything selected is now in the
// deck); otherwise say what was left out and why.
export function toastMergeRefImagesOutcome(
result: MergeRefImagesResult,
maxRefs: number,
): void {
if (result.overflow === 0) return;
if (result.added > 0) {
const sent = result.added + result.overflow;
toast.success(
`Added ${result.added} of ${sent} images — this model takes up to ${maxRefs} references`,
);
} else if (maxRefs === 0) {
toast.error("The selected model doesn't take image references");
} else {
toast.error(
`Couldn't add more images — this model's reference limit (${maxRefs}) is reached`,
);
}
}
@@ -35,10 +35,15 @@ type CreateImageState = {
ui: ImageUiState;
referenceImages: RefImage[];
pendingRecreate: RecreatePayload | null;
// Reference images sent from another page (library "Send to prompt").
// Consumed by the create-image page, which applies the real per-model cap.
pendingRefImages: RefImage[] | null;
setUi: (patch: Partial<ImageUiState>) => void;
setReferenceImages: (images: RefImage[]) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
setPendingRefImages: (refs: RefImage[] | null) => void;
consumePendingRefImages: () => RefImage[] | null;
startBatch: (
prompt: string,
requestedCount: number,
@@ -68,6 +73,7 @@ export const useCreateImageStore = create<CreateImageState>()(
ui: { ...DEFAULT_UI },
referenceImages: [],
pendingRecreate: null,
pendingRefImages: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
@@ -82,6 +88,14 @@ export const useCreateImageStore = create<CreateImageState>()(
return payload;
},
setPendingRefImages: (refs) => set({ pendingRefImages: refs }),
consumePendingRefImages: () => {
const refs = get().pendingRefImages;
if (refs) set({ pendingRefImages: null });
return refs;
},
startBatch: (prompt, requestedCount, modelLabel) => {
const id = crypto.randomUUID();
const batch: ImageBatch = {
@@ -46,6 +46,11 @@ import {
qualityFromLabel,
} from "./components/QualityPicker";
import { useImageCostEstimate } from "../../lib/cost-estimate-api";
import {
galleryItemToRefImage,
mergeRefImages,
toastMergeRefImagesOutcome,
} from "../../lib/send-to-prompt";
import {
resolveModelOption,
resolveModelCount,
@@ -315,6 +320,25 @@ export default function CreateImage() {
if (payload.modelId) setRecreateModelIdToVerify(payload.modelId);
}, [pendingRecreate, setUi]);
// Consume reference images sent from the library ("Send to prompt").
// Waits for the model list so the merge applies the real per-model cap —
// the sender doesn't know which model is selected here.
const pendingRefImages = useCreateImageStore((s) => s.pendingRefImages);
useEffect(() => {
if (!pendingRefImages || apiModels.length === 0) return;
const incoming = useCreateImageStore.getState().consumePendingRefImages();
if (!incoming || incoming.length === 0) return;
const result = mergeRefImages(referenceImages, incoming, maxImageRefs);
if (result.added > 0) setReferenceImages(result.next);
toastMergeRefImagesOutcome(result, maxImageRefs);
}, [
pendingRefImages,
apiModels,
referenceImages,
maxImageRefs,
setReferenceImages,
]);
// Resume polling for pending batches
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
@@ -365,13 +389,7 @@ export default function CreateImage() {
const availableSlots = Math.max(0, maxImageRefs - referenceImages.length);
const newImages: RefImage[] = items
.slice(0, availableSlots)
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
.map(galleryItemToRefImage);
setReferenceImages([...referenceImages, ...newImages]);
setIsImagePickerOpen(false);
},
@@ -51,10 +51,18 @@ type CreateVideoState = {
ui: VideoUiState;
refs: VideoRefsState;
pendingRecreate: RecreatePayload | null;
// Reference media sent from another page (library "Send to prompt").
// Consumed by the create-video page, which applies the real per-model caps.
pendingRefImages: RefImage[] | null;
pendingRefVideos: RefVideo[] | null;
setUi: (patch: Partial<VideoUiState>) => void;
setRefs: (patch: Partial<VideoRefsState>) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
setPendingRefImages: (refs: RefImage[] | null) => void;
consumePendingRefImages: () => RefImage[] | null;
setPendingRefVideos: (refs: RefVideo[] | null) => void;
consumePendingRefVideos: () => RefVideo[] | null;
startBatch: (prompt: string, modelLabel: string, batchCount?: number) => string;
setBatchJobToken: (batchId: string, jobToken: string) => void;
completeBatch: (batchId: string, video: GeneratedVideo) => void;
@@ -90,6 +98,8 @@ export const useCreateVideoStore = create<CreateVideoState>()(
ui: { ...DEFAULT_UI },
refs: { ...DEFAULT_REFS },
pendingRecreate: null,
pendingRefImages: null,
pendingRefVideos: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
@@ -105,6 +115,22 @@ export const useCreateVideoStore = create<CreateVideoState>()(
return payload;
},
setPendingRefImages: (refs) => set({ pendingRefImages: refs }),
consumePendingRefImages: () => {
const refs = get().pendingRefImages;
if (refs) set({ pendingRefImages: null });
return refs;
},
setPendingRefVideos: (refs) => set({ pendingRefVideos: refs }),
consumePendingRefVideos: () => {
const refs = get().pendingRefVideos;
if (refs) set({ pendingRefVideos: null });
return refs;
},
startBatch: (prompt, modelLabel, batchCount) => {
const id = crypto.randomUUID();
const batch: VideoBatch = {
@@ -55,6 +55,10 @@ import {
} from "../create-image/components/AspectRatioIcon";
import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker";
import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
import {
mergeRefImages,
toastMergeRefImagesOutcome,
} from "../../lib/send-to-prompt";
import {
resolveModelOption,
resolveModelCount,
@@ -203,6 +207,41 @@ function buildSizePopoverItems(
}));
}
// Caps candidate reference videos against the deck's slot and total-duration
// limits, probing each file's duration when it isn't known yet. Rejections
// toast per video. Shared by the library video picker and the "Send to
// prompt" consume flow.
async function buildVideoRefsToAdd(
candidates: RefVideo[],
existing: RefVideo[],
maxRefs: number,
maxTotalDuration: number,
): Promise<RefVideo[]> {
const slots = Math.max(0, maxRefs - existing.length);
const picked = candidates.slice(0, slots);
const added: RefVideo[] = [];
let total = existing.reduce((sum, v) => sum + v.duration, 0);
for (const candidate of picked) {
const duration =
candidate.duration > 0
? candidate.duration
: await getVideoDurationFromUrl(candidate.url);
if (duration <= 0) {
toast.error("Could not read video file");
continue;
}
if (total + duration > maxTotalDuration) {
toast.error(
`Video too long — max ${maxTotalDuration}s total (${maxTotalDuration - total}s remaining)`,
);
continue;
}
total += duration;
added.push({ ...candidate, duration });
}
return added;
}
// Effective max duration for the active input mode. Some models (e.g. Grok)
// cap duration lower when image references are used — that's reference mode here.
function maxDurationForMode(
@@ -747,6 +786,79 @@ export default function CreateVideo() {
});
}, [pendingRecreate, setUi]);
// Consume reference images sent from the library ("Send to prompt").
// Waits for the model list so the merge applies the real per-model cap —
// the sender doesn't know which model is selected here. Reference-capable
// models take the images as references (switching into reference mode so
// they're visible); keyframe-only models take the first as the start frame.
const pendingRefImages = useCreateVideoStore((s) => s.pendingRefImages);
useEffect(() => {
if (!pendingRefImages || apiModels.length === 0) return;
const incoming = useCreateVideoStore.getState().consumePendingRefImages();
if (!incoming || incoming.length === 0) return;
const supportsRefs = !!selectedModel?.image_references_supported;
const supportsKeyframe =
!!selectedModel?.starting_keyframe_supported ||
!!selectedModel?.starting_keyframe_required;
const maxImages = supportsRefs
? (selectedModel?.image_references_max ?? 3)
: supportsKeyframe
? 1
: 0;
const result = mergeRefImages(referenceImages, incoming, maxImages);
if (result.added > 0) {
setReferenceImages(result.next);
if (supportsRefs) setUi({ inputMode: "reference" });
}
toastMergeRefImagesOutcome(result, maxImages);
}, [
pendingRefImages,
apiModels,
selectedModel,
referenceImages,
setReferenceImages,
setUi,
]);
// Consume reference videos sent from the library ("Send to prompt").
// Only some models (e.g. Seedance) take video references — anything else
// gets a toast instead of a silently hidden deck.
const pendingRefVideos = useCreateVideoStore((s) => s.pendingRefVideos);
useEffect(() => {
if (!pendingRefVideos || apiModels.length === 0) return;
const incoming = useCreateVideoStore.getState().consumePendingRefVideos();
if (!incoming || incoming.length === 0) return;
if (!supportsVideoRefs) {
toast.error(
"The selected model doesn't support video references — pick one that does (e.g. Seedance)",
);
return;
}
const existingTokens = new Set(referenceVideos.map((v) => v.mediaToken));
const fresh = incoming.filter((v) => !existingTokens.has(v.mediaToken));
void (async () => {
const added = await buildVideoRefsToAdd(
fresh,
referenceVideos,
maxVideoRefs,
maxVideoRefDuration,
);
if (added.length > 0) {
setReferenceVideos([...referenceVideos, ...added]);
setUi({ inputMode: "reference" });
}
})();
}, [
pendingRefVideos,
apiModels,
supportsVideoRefs,
referenceVideos,
maxVideoRefs,
maxVideoRefDuration,
setReferenceVideos,
setUi,
]);
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
const pendingBatches = useCreateVideoStore
@@ -930,34 +1042,21 @@ export default function CreateVideo() {
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({
const candidates: RefVideo[] = items
.filter((item) => !!item.fullImage)
.map((item) => ({
id: Math.random().toString(36).substring(7),
url,
url: item.fullImage!,
file: new File([], "library-video"),
mediaToken: item.id,
duration,
});
}
duration: 0,
}));
const added = await buildVideoRefsToAdd(
candidates,
referenceVideos,
maxVideoRefs,
maxVideoRefDuration,
);
if (added.length > 0) {
setReferenceVideos([...referenceVideos, ...added]);
}
@@ -59,6 +59,12 @@ import {
import { Lightbox } from "../../components/lightbox/lightbox";
import { toast } from "../../components/toast/toast";
import { downloadItemsAsZip } from "../../lib/download-media-zip";
import {
isImagePromptable,
isVideoPromptable,
sendToPrompt,
type PromptDestination,
} from "../../lib/send-to-prompt";
import {
useLibraryFoldersStore,
useLibrarySelectionStore,
@@ -2061,6 +2067,15 @@ const LibraryTile = memo(function LibraryTile({
// Owns its own subscription to the selection store (and the folder popover
// state), so selection changes re-render only this small bar, never the page.
const SEND_TO_TARGETS: {
destination: PromptDestination;
label: string;
icon: typeof faImage;
}[] = [
{ destination: "image", label: "Create image", icon: faImage },
{ destination: "video", label: "Create video", icon: faVideo },
];
interface BulkSelectionBarProps {
allItems: GalleryItem[];
folders: UiFolder[];
@@ -2081,7 +2096,9 @@ function BulkSelectionBar({
const ids = useLibrarySelectionStore((s) => s.ids);
const folderMediaItems = useLibraryFoldersStore((s) => s.folderMediaItems);
const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems);
const navigate = useNavigate();
const [popoverOpen, setPopoverOpen] = useState(false);
const [sendToOpen, setSendToOpen] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<{
done: number;
total: number;
@@ -2120,6 +2137,8 @@ function BulkSelectionBar({
if (ids.size === 0) return null;
const clear = () => useLibrarySelectionStore.getState().clear();
const hasPromptImages = selectedItems.some(isImagePromptable);
const hasPromptVideos = selectedItems.some(isVideoPromptable);
const handleDownloadSelected = async () => {
const downloadable = selectedItems.filter(
@@ -2178,6 +2197,56 @@ function BulkSelectionBar({
{ids.size} selected
</span>
{/* Send to prompt (only when the selection contains images or videos).
Images can go to either prompt; videos only to the video prompt. */}
{(hasPromptImages || hasPromptVideos) && (
<div className="relative">
<button
type="button"
title="Send to prompt"
onClick={() => setSendToOpen((v) => !v)}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
>
<FontAwesomeIcon
icon={hasPromptImages ? faImage : faVideo}
className="text-xs"
/>
{/* Icon-only on phones — a fourth labeled button overflows the bar. */}
<span className="hidden sm:inline">Send to prompt</span>
</button>
{sendToOpen && (
<>
<div
className="fixed inset-0 z-[59]"
onClick={() => setSendToOpen(false)}
/>
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
Use as reference in
</div>
{SEND_TO_TARGETS.filter(
(target) => target.destination === "video" || hasPromptImages,
).map((target) => (
<button
key={target.destination}
type="button"
onClick={() => {
setSendToOpen(false);
sendToPrompt(selectedItems, target.destination, navigate);
clear();
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
>
<FontAwesomeIcon icon={target.icon} className="w-4 text-xs" />
<span>{target.label}</span>
</button>
))}
</div>
</>
)}
</div>
)}
{/* Add to folder */}
<div className="relative">
<button