model selector 2.5 coming soon removal and reference deplicated tokens bug fix (#1828)

## Fixes & improvements

### Model picker
- Removed the "Seedance 2.5 - SOON" placeholder from the video model
picker; it was confusing now that Seedance 2.5 Preview is live.

### "Not all media files could be found" error
- Fixed generations failing when the same media file ended up twice in
one reference list (e.g. added as two references, or the same file
uploaded twice).
- Requests now automatically drop duplicate media references before
sending - applies to image, video, audio, mesh, and splat generation on
both the web app and the desktop app.

### Gallery picker UX
- Media already added to a slot is now greyed out with an "Added" badge
in the library picker, so the same file can't be added twice to the same
field.
- Applies per slot across the create pages (video, image, audio, 3D
object, world) on web and desktop - references, start frame, end frame,
video refs, audio refs, and multi-view slots.
- Reusing the same image across different slots (e.g. start *and* end
frame, or two 3D views) is still allowed.
This commit is contained in:
BFlat authored and GitHub committed 2026-08-02 07:32:41 +07:00
1 parent c026a7c572
commit 9cdad367c0
18 files changed
+275 -51

No files matched your search

@@ -377,6 +377,24 @@ export default function CreateAudio() {
[maxImageRefs, handleReferenceImagesChange],
);
// Refs already in each slot, greyed out in that slot's picker so the same
// media file can't be added twice to one field.
const usedImageTokens = useMemo(
() =>
referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t),
[referenceImages],
);
const usedAudioTokens = useMemo(
() =>
referenceAudios
.map((audio) => audio.mediaToken)
.filter((t): t is string => !!t),
[referenceAudios],
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
@@ -868,6 +886,7 @@ export default function CreateAudio() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={maxImageRefs}
onUseSelected={handleLibraryImageSelect}
@@ -879,6 +898,7 @@ export default function CreateAudio() {
isOpen={isAudioPickerOpen}
onClose={() => setIsAudioPickerOpen(false)}
selectedItemIds={audioPickerSelectedIds}
disabledItemIds={usedAudioTokens}
onSelectItem={handleAudioPickerSelect}
maxSelections={audioPickerMax}
onUseSelected={handleLibraryAudioSelect}
@@ -396,6 +396,16 @@ export default function CreateImage() {
[maxImageRefs, referenceImages, setReferenceImages],
);
// Refs already in the deck, greyed out in the picker so the same media file
// can't be added twice to the reference list.
const usedImageTokens = useMemo(
() =>
referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t),
[referenceImages],
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
@@ -766,6 +776,7 @@ export default function CreateImage() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -341,6 +341,22 @@ export default function CreateObject() {
[libraryTarget, setReferenceImages, setInputs],
);
// The picker replaces a single-image slot, so only that slot's current image
// is greyed out; reusing an image across slots stays allowed.
const disabledLibraryTokens = useMemo(() => {
const slotToken =
libraryTarget === "primary"
? referenceImages[0]?.mediaToken
: libraryTarget === "front"
? inputs.frontImage?.mediaToken
: libraryTarget === "back"
? inputs.backImage?.mediaToken
: libraryTarget === "left"
? inputs.leftImage?.mediaToken
: inputs.rightImage?.mediaToken;
return slotToken ? [slotToken] : [];
}, [libraryTarget, referenceImages, inputs]);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
@@ -704,6 +720,7 @@ export default function CreateObject() {
isOpen={libraryTarget !== null}
onClose={() => setLibraryTarget(null)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={disabledLibraryTokens}
onSelectItem={(id) => setPickerSelectedIds([id])}
maxSelections={1}
onUseSelected={handleLibraryImageSelect}
@@ -142,13 +142,6 @@ const LABEL_TO_BITRATE: Record<string, string> = Object.fromEntries(
// ── Model lookup ─────────────────────────────────────────────────────────
// Placeholder entries shown in the picker as disabled "SOON" items. These are
// UI-only — they are not in `_modelLookup`, so the click handler is a no-op
// (the `disabled` flag also prevents the row from firing).
const COMING_SOON_MODELS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "seedance_2p1", label: "Seedance 2.5" },
];
let _modelLookup = new Map<string, OmniGenVideoModelInfo>();
function buildModelPopoverItems(
@@ -156,7 +149,7 @@ function buildModelPopoverItems(
selectedId: string,
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
const apiItems: PopoverItem[] = models.map((model) => ({
return models.map((model) => ({
label: model.full_name || model.model,
selected: model.model === selectedId,
description: getModelDescription(model.model, model.extra_info_short),
@@ -170,26 +163,6 @@ function buildModelPopoverItems(
),
action: model.model,
}));
const comingSoonItems: PopoverItem[] = COMING_SOON_MODELS.map(
({ id, label }) => ({
label,
selected: false,
disabled: true,
icon: (
<img
src={getCreatorIconPathForModelId(id)}
alt={`${id} logo`}
className="h-5 w-5 icon-auto-contrast"
/>
),
trailing: (
<span className="shrink-0 rounded-full bg-white/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
Soon
</span>
),
}),
);
return [...apiItems, ...comingSoonItems];
}
function buildSizePopoverItems(
@@ -1158,6 +1131,38 @@ export default function CreateVideo() {
setIsEndFramePickerOpen(false);
}, []);
// Each picker only greys out tokens already in its own slot, so the same
// media file can't be added twice to one field. Reusing an image across
// slots (e.g. the same image as start AND end frame) stays allowed.
const usedImageTokens = useMemo(
() =>
referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t),
[referenceImages],
);
const usedEndFrameTokens = useMemo(
() => (endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : []),
[endFrameImage],
);
const usedVideoTokens = useMemo(
() =>
referenceVideos
.map((v) => v.mediaToken)
.filter((t): t is string => !!t),
[referenceVideos],
);
const usedAudioTokens = useMemo(
() =>
referenceAudios
.map((a) => a.mediaToken)
.filter((t): t is string => !!t),
[referenceAudios],
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
@@ -1938,6 +1943,7 @@ export default function CreateVideo() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -1949,6 +1955,7 @@ export default function CreateVideo() {
isOpen={isEndFramePickerOpen}
onClose={() => setIsEndFramePickerOpen(false)}
selectedItemIds={endFramePickerSelectedIds}
disabledItemIds={usedEndFrameTokens}
onSelectItem={handleEndFramePickerSelect}
maxSelections={1}
onUseSelected={handleEndFrameLibrarySelect}
@@ -1960,6 +1967,7 @@ export default function CreateVideo() {
isOpen={isVideoRefPickerOpen}
onClose={() => setIsVideoRefPickerOpen(false)}
selectedItemIds={videoRefPickerSelectedIds}
disabledItemIds={usedVideoTokens}
onSelectItem={handleVideoRefPickerSelect}
maxSelections={videoRefPickerMax}
onUseSelected={handleLibraryVideoSelect}
@@ -1971,6 +1979,7 @@ export default function CreateVideo() {
isOpen={isAudioRefPickerOpen}
onClose={() => setIsAudioRefPickerOpen(false)}
selectedItemIds={audioRefPickerSelectedIds}
disabledItemIds={usedAudioTokens}
onSelectItem={handleAudioRefPickerSelect}
maxSelections={audioRefPickerMax}
onUseSelected={handleLibraryAudioSelect}
@@ -287,6 +287,16 @@ export default function CreateWorld() {
[maxImageRefs, referenceImages, setReferenceImages],
);
// Refs already in the deck, greyed out in the picker so the same media file
// can't be added twice to the reference list.
const usedImageTokens = useMemo(
() =>
referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t),
[referenceImages],
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
@@ -561,6 +571,7 @@ export default function CreateWorld() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -298,6 +298,16 @@ export default function CreateImage() {
[maxImageRefs, referenceImages, setReferenceImages],
);
// Refs already in the deck, greyed out in the picker so the same media file
// can't be added twice to the reference list.
const usedImageTokens = useMemo(
() =>
referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t),
[referenceImages],
);
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || isGenerating) return;
if (!selectedModel) {
@@ -521,6 +531,7 @@ export default function CreateImage() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -738,6 +738,22 @@ export default function CreateVideo() {
setIsEndFramePickerOpen(false);
}, []);
// Each picker only greys out tokens already in its own slot, so the same
// media file can't be added twice to one field. Reusing an image across
// slots (e.g. the same image as start AND end frame) stays allowed.
const usedImageTokens = useMemo(
() =>
referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t),
[referenceImages],
);
const usedEndFrameTokens = useMemo(
() => (endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : []),
[endFrameImage],
);
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || isGeneratingRef.current) {
console.log("[generate-video] blocked", {
@@ -1210,6 +1226,7 @@ export default function CreateVideo() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -1221,6 +1238,7 @@ export default function CreateVideo() {
isOpen={isEndFramePickerOpen}
onClose={() => setIsEndFramePickerOpen(false)}
selectedItemIds={endFramePickerSelectedIds}
disabledItemIds={usedEndFrameTokens}
onSelectItem={handleEndFramePickerSelect}
maxSelections={1}
onUseSelected={handleEndFrameLibrarySelect}
+20 -5
View File
@@ -443,6 +443,21 @@ function stripNulls(obj: object): Record<string, unknown> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
}
/** Dedupe every `*_media_tokens` list in the request. The backend batch-looks-up
* all media tokens at once and rejects the whole request when the same token
* appears twice (found count != token count), which happens when one file is
* added as two references — or two uploads of the same file hash-dedupe into
* one media token. */
function dedupeMediaTokenLists(obj: object): Record<string, unknown> {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) =>
key.endsWith("_media_tokens") && Array.isArray(value)
? [key, [...new Set(value)]]
: [key, value],
),
);
}
// ── API class ────────────────────────────────────────────────────────────
export class OmniGenApi extends ApiManager {
@@ -552,7 +567,7 @@ export class OmniGenApi extends ApiManager {
): Promise<OmniGenImageGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenImageGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/image`,
body: stripNulls(body),
body: stripNulls(dedupeMediaTokenLists(body)),
});
}
@@ -561,7 +576,7 @@ export class OmniGenApi extends ApiManager {
): Promise<OmniGenVideoGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenVideoGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/video`,
body: stripNulls(body),
body: stripNulls(dedupeMediaTokenLists(body)),
});
}
@@ -570,7 +585,7 @@ export class OmniGenApi extends ApiManager {
): Promise<OmniGenAudioGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenAudioGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/audio`,
body: stripNulls(body),
body: stripNulls(dedupeMediaTokenLists(body)),
});
}
@@ -579,7 +594,7 @@ export class OmniGenApi extends ApiManager {
): Promise<OmniGenMeshGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenMeshGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/mesh`,
body: stripNulls(body),
body: stripNulls(dedupeMediaTokenLists(body)),
});
}
@@ -588,7 +603,7 @@ export class OmniGenApi extends ApiManager {
): Promise<OmniGenSplatGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenSplatGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/splat`,
body: stripNulls(body),
body: stripNulls(dedupeMediaTokenLists(body)),
});
}
}
@@ -46,6 +46,9 @@ interface GalleryDraggableItemProps {
mode: ModalMode;
activeFilter: string;
selected: boolean;
/** Select mode: the item is already in the destination (e.g. the reference
* deck), so it's greyed out and can't be picked again. */
disabled?: boolean;
onClick: () => void;
onImageError: (e: React.SyntheticEvent<HTMLImageElement>) => void;
/** Resolved prompt text for audio items — shown on the tile since audio has
@@ -74,6 +77,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
mode,
activeFilter,
selected,
disabled = false,
onClick,
onImageError,
audioPromptText,
@@ -234,6 +238,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
// selection when bulk-selecting / in select mode (handled by the host's onClick).
const handleButtonClick = (event: React.MouseEvent) => {
if (event.button !== 0) return;
if (disabled) return;
if (dragStarted.current) return;
const globalDrag = galleryDnd.getDragState();
if (globalDrag.isDragging) return;
@@ -252,14 +257,18 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
"w-full group relative overflow-visible rounded-md border-[3px] transition-colors outline-none focus:outline-none focus-visible:outline-none active:outline-none aspect-square",
selected || bulkSelected
? "border-primary"
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
mode === "select"
? "cursor-pointer"
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
: disabled
? "border-transparent"
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
disabled
? "cursor-not-allowed opacity-40"
: mode === "select"
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
)}
onPointerDown={handlePointerDown}
onClick={handleButtonClick}
@@ -318,6 +327,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
<FontAwesomeIcon icon={faCheck} className="text-sm" />
</div>
)}
{disabled && (
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
Added
</div>
)}
</div>
</button>
);
@@ -268,6 +268,10 @@ interface GalleryModalProps {
onClose?: () => void;
mode: ModalMode;
selectedItemIds?: string[];
/** Select mode: ids that are already added to the destination (e.g. the
* reference deck). Shown greyed out with an "Added" badge and unclickable,
* so the same media file can't be picked twice. */
disabledItemIds?: string[];
onSelectItem?: (id: string) => void;
maxSelections?: number;
onUseSelected?: (selectedItems: GalleryItem[]) => void;
@@ -573,6 +577,7 @@ export const GalleryModal = React.memo(
onClose,
mode = "view",
selectedItemIds = EMPTY_SELECTED_IDS,
disabledItemIds = EMPTY_SELECTED_IDS,
onSelectItem,
maxSelections = 4,
onUseSelected,
@@ -1550,6 +1555,7 @@ export const GalleryModal = React.memo(
const handleItemClick = useCallback(
(item: GalleryItem) => {
if (mode === "select" && onSelectItem) {
if (disabledItemIds.includes(item.id)) return;
onSelectItem(item.id);
} else if (bulkSelectionMode) {
toggleBulkSelect(item.id);
@@ -1559,7 +1565,7 @@ export const GalleryModal = React.memo(
galleryModalLightboxMediaId.value = item.id;
}
},
[mode, onSelectItem, bulkSelectionMode, toggleBulkSelect],
[mode, onSelectItem, disabledItemIds, bulkSelectionMode, toggleBulkSelect],
);
const handleCloseLightbox = useCallback(() => {
@@ -3121,6 +3127,10 @@ export const GalleryModal = React.memo(
activeFilter={activeFilter}
audioPromptText={audioPromptTextFor(item)}
selected={selectedItemIds.includes(item.id)}
disabled={
mode === "select" &&
disabledItemIds.includes(item.id)
}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
e.currentTarget.src =
@@ -3285,6 +3295,10 @@ export const GalleryModal = React.memo(
activeFilter={activeFilter}
audioPromptText={audioPromptTextFor(item)}
selected={selectedItemIds.includes(item.id)}
disabled={
mode === "select" &&
disabledItemIds.includes(item.id)
}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
e.currentTarget.src =
@@ -3434,6 +3448,10 @@ export const GalleryModal = React.memo(
selected={selectedItemIds.includes(
item.id,
)}
disabled={
mode === "select" &&
disabledItemIds.includes(item.id)
}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
e.currentTarget.src =
@@ -384,6 +384,23 @@ export const ImagePromptRow = ({
[referenceVideos],
);
// Tokens already in the slot the picker targets, greyed out in the gallery
// so one media file can't be added twice to the same field. Reusing an image
// across slots (e.g. start AND end frame) stays allowed.
const disabledGalleryIds = useMemo(() => {
if (galleryTarget === "video") {
return referenceVideos
.map((video) => video.mediaToken)
.filter((t): t is string => !!t);
}
if (galleryTarget === "end") {
return endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : [];
}
return referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t);
}, [galleryTarget, referenceImages, referenceVideos, endFrameImage]);
const handleVideoFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
@@ -1320,6 +1337,7 @@ export const ImagePromptRow = ({
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
disabledItemIds={disabledGalleryIds}
onSelectItem={handleImageSelect}
maxSelections={
galleryTarget === "end"
@@ -218,6 +218,17 @@ export const PromptBoxAudio = ({
maxAudioRefs - referenceAudios.length,
);
// Audio refs already in the deck, greyed out in the library picker so one
// media file can't be added twice — the backend rejects a generation request
// when the same token appears in it twice.
const addedAudioTokens = useMemo(
() =>
referenceAudios
.map((audio) => audio.mediaToken)
.filter((t): t is string => !!t),
[referenceAudios],
);
const handleAudioLibrarySelectToggle = (id: string) => {
setAudioLibrarySelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
@@ -631,6 +642,7 @@ export const PromptBoxAudio = ({
isOpen={isAudioLibraryOpen}
onClose={closeAudioLibrary}
selectedItemIds={audioLibrarySelectedIds}
disabledItemIds={addedAudioTokens}
onSelectItem={handleAudioLibrarySelectToggle}
maxSelections={maxAudioLibrarySelections}
onUseSelected={handleAudioLibraryUseSelected}
@@ -496,6 +496,7 @@ export const PromptBoxVideo = ({
referenceImages,
setReferenceImages,
maxImages: maxImageCount,
endFrameImage,
setEndFrameImage,
referenceVideos,
setReferenceVideos,
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
@@ -13,6 +13,8 @@ import {
export interface DeckRefLike {
id: string;
url: string;
/** Set once the media file exists server-side (uploads fill it in late). */
mediaToken?: string;
}
export interface DeckMediaRefLike extends DeckRefLike {
duration: number;
@@ -33,6 +35,9 @@ export interface UseDeckMediaOptions<
referenceImages: TImage[];
setReferenceImages: (images: TImage[]) => void;
maxImages: number;
/** Current end keyframe, when the host tracks one — its token is greyed out
* in the end-frame picker so it can't be re-picked into the same slot. */
endFrameImage?: TImage;
setEndFrameImage?: (image?: TImage) => void;
referenceVideos?: TVideo[];
setReferenceVideos?: (videos: TVideo[]) => void;
@@ -115,6 +120,7 @@ export function useDeckMedia<
referenceImages,
setReferenceImages,
maxImages,
endFrameImage,
setEndFrameImage,
referenceVideos = [],
setReferenceVideos,
@@ -704,6 +710,34 @@ export function useDeckMedia<
</>
);
// Tokens already in the deck slot the picker targets, greyed out in the
// gallery so one media file can't be added twice to the same field. Reusing
// an image across slots (e.g. start AND end frame) stays allowed.
const disabledGalleryIds = useMemo(() => {
if (galleryTarget === "video") {
return referenceVideos
.map((video) => video.mediaToken)
.filter((t): t is string => !!t);
}
if (galleryTarget === "audio") {
return referenceAudios
.map((audio) => audio.mediaToken)
.filter((t): t is string => !!t);
}
if (galleryTarget === "end") {
return endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : [];
}
return referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => !!t);
}, [
galleryTarget,
referenceImages,
referenceVideos,
referenceAudios,
endFrameImage,
]);
const galleryModal: ReactNode = ownGalleryModal ? (
<GalleryModal
key={
@@ -717,6 +751,7 @@ export function useDeckMedia<
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
disabledItemIds={disabledGalleryIds}
onSelectItem={handleImageSelect}
maxSelections={
galleryTarget === "end"
@@ -0,0 +1,11 @@
/**
* Dedupe a media token list before sending it to the backend.
*
* The backend batch-looks-up every media token in a request at once and rejects the
* whole request when the same token appears twice (found count != token count) — e.g.
* one file added as two references, or two uploads of the same file hash-deduped into
* a single media token.
*/
export function uniqueTokens(tokens: string[]): string[] {
return [...new Set(tokens)];
}
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { uniqueTokens } from "../common/UniqueTokens";
import { Model } from "@storyteller/model-list";
import { GenerationProvider } from "@storyteller/api-enums";
@@ -153,7 +154,7 @@ export const EnqueueImageToVideo = async (
request.reference_image_media_tokens.length > 0
) {
mutableRequest.reference_image_media_tokens =
request.reference_image_media_tokens;
uniqueTokens(request.reference_image_media_tokens);
}
if (
@@ -161,7 +162,7 @@ export const EnqueueImageToVideo = async (
request.reference_video_media_tokens.length > 0
) {
mutableRequest.reference_video_media_tokens =
request.reference_video_media_tokens;
uniqueTokens(request.reference_video_media_tokens);
}
if (
@@ -169,7 +170,7 @@ export const EnqueueImageToVideo = async (
request.reference_audio_media_tokens.length > 0
) {
mutableRequest.reference_audio_media_tokens =
request.reference_audio_media_tokens;
uniqueTokens(request.reference_audio_media_tokens);
}
if (
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { uniqueTokens } from "../common/UniqueTokens";
import { Model } from "@storyteller/model-list";
import { GenerationProvider } from "@storyteller/api-enums";
@@ -158,7 +159,7 @@ export const GenerateVideoCommand = async (
request.reference_image_media_tokens.length > 0
) {
mutableRequest.reference_image_media_tokens =
request.reference_image_media_tokens;
uniqueTokens(request.reference_image_media_tokens);
}
if (
@@ -166,7 +167,7 @@ export const GenerateVideoCommand = async (
request.reference_video_media_tokens.length > 0
) {
mutableRequest.reference_video_media_tokens =
request.reference_video_media_tokens;
uniqueTokens(request.reference_video_media_tokens);
}
if (
@@ -174,7 +175,7 @@ export const GenerateVideoCommand = async (
request.reference_audio_media_tokens.length > 0
) {
mutableRequest.reference_audio_media_tokens =
request.reference_audio_media_tokens;
uniqueTokens(request.reference_audio_media_tokens);
}
if (
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { uniqueTokens } from "../common/UniqueTokens";
import {
CommonAspectRatio,
CommonResolution,
@@ -127,13 +128,13 @@ export const GenerateVideo = async (
mutableRequest.end_frame_image_media_token = request.end_frame_image_media_token;
}
if (!!request.reference_image_media_tokens && request.reference_image_media_tokens.length > 0) {
mutableRequest.reference_image_media_tokens = request.reference_image_media_tokens;
mutableRequest.reference_image_media_tokens = uniqueTokens(request.reference_image_media_tokens);
}
if (!!request.reference_video_media_tokens && request.reference_video_media_tokens.length > 0) {
mutableRequest.reference_video_media_tokens = request.reference_video_media_tokens;
mutableRequest.reference_video_media_tokens = uniqueTokens(request.reference_video_media_tokens);
}
if (!!request.reference_audio_media_tokens && request.reference_audio_media_tokens.length > 0) {
mutableRequest.reference_audio_media_tokens = request.reference_audio_media_tokens;
mutableRequest.reference_audio_media_tokens = uniqueTokens(request.reference_audio_media_tokens);
}
if (!!request.reference_character_tokens && request.reference_character_tokens.length > 0) {
mutableRequest.reference_character_tokens = request.reference_character_tokens;