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:
1 parent
c026a7c572
commit
9cdad367c0
18 files changed
+269
-45
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}
|
||||
|
||||
@@ -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,10 +257,14 @@ 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"
|
||||
: disabled
|
||||
? "border-transparent"
|
||||
: disableTooltipAndBadge
|
||||
? "border-transparent hover:border-primary/80"
|
||||
: "border-transparent hover:border-primary",
|
||||
mode === "select"
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: mode === "select"
|
||||
? "cursor-pointer"
|
||||
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
|
||||
? "cursor-pointer"
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user