Library/video promptbox: bulk "Add tags" action + default video mode to Seedance 2.5 (#1864)

- Select multiple items in the library and add tags to all of them at
once
- Tag suggestions pop up as you type, based on your existing tags
- Fixed the selection bar looking squished (buttons no longer wrap onto
two lines)
- Video generation now defaults to Seedance 2.5 (was 2.0)
- Omni Reference is the default video input mode for everyone
This commit is contained in:
BFlat authored and GitHub committed 2026-08-08 15:41:13 +07:00
1 parent b7f354face
commit 4d839d3b8b
6 files changed
+163 -16

No files matched your search

@@ -187,10 +187,11 @@ export const useCreateVideoStore = create<CreateVideoState>()(
}),
{
name: "artcraft-video-batches",
// Bumped to 1 when reference became the default input mode: the
// migration runs once for pre-existing persisted state and resets the
// stored mode so everyone lands on the new default.
version: 1,
// Bumped to 1 when reference became the default input mode, and to 2
// when Seedance 2.5 replaced 2.0 as the default model: each migration
// runs once for pre-existing persisted state so everyone lands on the
// new defaults.
version: 2,
migrate: (persisted, version) => {
const p = (persisted ?? {}) as {
batches?: VideoBatch[];
@@ -200,6 +201,16 @@ export const useCreateVideoStore = create<CreateVideoState>()(
if (version < 1) {
ui.inputMode = "reference";
}
if (version < 2) {
// Seedance 2.5 replaced 2.0 as the default: users still on the old
// default follow it (null resolves to DEFAULT_MODEL_ID at read
// time); explicit picks of other models are left alone. Everyone
// lands back on Omni Reference as the default input mode.
if (ui.selectedModelId === "seedance_2p0") {
ui.selectedModelId = null;
}
ui.inputMode = "reference";
}
return { batches: p.batches ?? [], ui };
},
// Persist prompt + lightweight settings alongside pending batches so a
@@ -88,7 +88,7 @@ import { toast } from "../../components/toast/toast";
// ── Constants ────────────────────────────────────────────────────────────
const DEFAULT_MODEL_ID = "seedance_2p0";
const DEFAULT_MODEL_ID = "seedance_2p5";
// Models where character @-mentions are intentionally held back in the UI for now,
// even though the API reports character_references_supported. Remove an entry to enable.
@@ -637,10 +637,10 @@ export default function CreateVideo() {
activeCharacters,
]);
// Seedance 2.0 models support @-mention references in the prompt — once at
// Seedance 2.x models support @-mention references in the prompt — once at
// least one ref is attached, surface that in the placeholder.
const isSeedance2Model = (selectedModel?.model ?? "").startsWith(
"seedance_2p0",
"seedance_2",
);
const hasUploadedRefs =
referenceImages.length > 0 ||
@@ -36,6 +36,10 @@ interface LibraryTagsState {
loadTagMedia: (tagToken: string, reset?: boolean) => Promise<void>;
renameTag: (tagToken: string, newValue: string) => Promise<void>;
deleteTag: (tagToken: string) => Promise<void>;
/** Add tags to many media files at once (bulk selection bar). */
bulkAddTags: (mediaFileTokens: string[], tags: string[]) => Promise<boolean>;
/** Drop cached media for tags whose contents changed; the open tag reloads. */
invalidateTagMedia: (tagTokens: string[]) => void;
/** Merge canonical tags returned by add/set calls (fresh use counts). */
upsertTags: (tags: TagDetails[]) => void;
setRenameTarget: (tagToken: string | null) => void;
@@ -193,6 +197,40 @@ export const useLibraryTagsStore = create<LibraryTagsState>((set, get) => ({
}
},
bulkAddTags: async (mediaFileTokens, tags) => {
try {
const res = await tagsApi.BulkAddTags({ mediaFileTokens, tags });
if (!res.success || !res.data) {
toast.error(res.errorMessage || "Failed to add tags.");
return false;
}
// Fresh use counts for the sidebar; tagged listings must refetch.
get().upsertTags(res.data.tags);
get().invalidateTagMedia(res.data.tags.map((t) => t.tag_token));
const count = res.data.accepted_media_file_tokens.length;
toast.success(`Tagged ${count} ${count === 1 ? "item" : "items"}`);
return true;
} catch (err) {
toast.error(`Failed to add tags: ${errMsg(err)}`);
return false;
}
},
invalidateTagMedia: (tagTokens) => {
if (tagTokens.length === 0) return;
set((s) => {
const nextMedia = { ...s.tagMediaItems };
const nextHasMore = { ...s.tagHasMore };
for (const token of tagTokens) {
delete nextMedia[token];
delete nextHasMore[token];
}
return { tagMediaItems: nextMedia, tagHasMore: nextHasMore };
});
const active = get().activeTagToken;
if (active && tagTokens.includes(active)) get().loadTagMedia(active, true);
},
upsertTags: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
@@ -25,6 +25,10 @@ import {
FOLDER_DROP_EVENT,
type GalleryItem,
} from "@storyteller/ui-gallery-modal";
import {
TagChipInput,
type TagSuggestion,
} from "@storyteller/ui-lightbox-modal";
import { PLACEHOLDER_IMAGES, is3DModelUrl } from "@storyteller/common";
import { is3DMediaClass } from "@storyteller/ui-generation-list";
import {
@@ -2077,6 +2081,11 @@ const SEND_TO_TARGETS: {
{ destination: "video", label: "Create video", icon: faVideo },
];
// Shared styling for the bar's action pills. `whitespace-nowrap` keeps labels
// on a single line — the bar grows instead of squishing buttons into two rows.
const BAR_BUTTON_CLASS =
"flex items-center gap-2 whitespace-nowrap rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors";
interface BulkSelectionBarProps {
allItems: GalleryItem[];
folders: UiFolder[];
@@ -2100,6 +2109,9 @@ function BulkSelectionBar({
const navigate = useNavigate();
const [popoverOpen, setPopoverOpen] = useState(false);
const [sendToOpen, setSendToOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagChips, setTagChips] = useState<string[]>([]);
const [addingTags, setAddingTags] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<{
done: number;
total: number;
@@ -2113,11 +2125,22 @@ function BulkSelectionBar({
[folders],
);
// Close the popover when navigating so it doesn't dangle over the new view.
// Close the popovers when navigating so they don't dangle over the new view.
useEffect(() => {
setPopoverOpen(false);
setTagsOpen(false);
}, [activeFolderId]);
// Autocomplete for the Add-tags popover: the user's tags, most-used first.
const storeTags = useLibraryTagsStore((s) => s.tags);
const tagSuggestions = useMemo(
(): TagSuggestion[] =>
[...storeTags]
.sort(compareTagsByUseCount)
.map((t) => ({ value: t.value, useCount: t.useCount })),
[storeTags],
);
// Resolve selected items from everything loaded (root library + folder/tag
// caches) — the selection survives navigation, so selected items may not be
// part of the currently displayed view.
@@ -2173,6 +2196,20 @@ function BulkSelectionBar({
}
};
const handleAddTags = async () => {
if (addingTags || tagChips.length === 0) return;
setAddingTags(true);
const ok = await useLibraryTagsStore
.getState()
.bulkAddTags(Array.from(ids), tagChips);
setAddingTags(false);
if (ok) {
setTagsOpen(false);
setTagChips([]);
clear();
}
};
return (
<div
data-no-marquee
@@ -2194,7 +2231,7 @@ function BulkSelectionBar({
</div>
)}
</div>
<span className="px-1 text-sm font-medium text-white/80">
<span className="whitespace-nowrap px-1 text-sm font-medium text-white/80">
{ids.size} selected
</span>
@@ -2206,7 +2243,7 @@ function BulkSelectionBar({
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"
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon
icon={hasPromptImages ? faImage : faVideo}
@@ -2259,7 +2296,7 @@ function BulkSelectionBar({
clear();
navigate(`/frame-extractor?media=${token}`);
}}
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"
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon icon={faPhotoFilm} className="text-xs" />
<span className="hidden sm:inline">Extract frames</span>
@@ -2270,11 +2307,13 @@ function BulkSelectionBar({
<div className="relative">
<button
type="button"
title="Add to folder"
onClick={() => setPopoverOpen((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"
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon icon={faFolderPlus} className="text-xs" />
Add to folder
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
<span className="hidden sm:inline">Add to folder</span>
</button>
{popoverOpen && (
<>
@@ -2334,11 +2373,61 @@ function BulkSelectionBar({
)}
</div>
{/* Add tags */}
<div className="relative">
<button
type="button"
title="Add tags"
onClick={() => {
setTagsOpen((v) => !v);
// Lazy-load the user's tags for autocomplete on first open.
if (!useLibraryTagsStore.getState().tagsLoaded) {
void useLibraryTagsStore.getState().loadTags();
}
}}
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon icon={faTags} className="text-xs" />
<span className="hidden sm:inline">Add tags</span>
</button>
{tagsOpen && (
<>
<div
className="fixed inset-0 z-[59]"
onClick={() => setTagsOpen(false)}
/>
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 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">
Add tags
</div>
<TagChipInput
chips={tagChips}
suggestions={tagSuggestions}
dropUp
onAdd={(values) => setTagChips((prev) => [...prev, ...values])}
onRemove={(value) =>
setTagChips((prev) => prev.filter((c) => c !== value))
}
/>
<Button
variant="primary"
disabled={tagChips.length === 0}
loading={addingTags}
onClick={handleAddTags}
className="mt-2 w-full justify-center rounded-md text-sm py-1.5"
>
Tag {ids.size} {ids.size === 1 ? "item" : "items"}
</Button>
</div>
</>
)}
</div>
<button
type="button"
onClick={handleDownloadSelected}
disabled={isDownloading}
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 disabled:opacity-60"
className={`${BAR_BUTTON_CLASS} disabled:opacity-60`}
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
@@ -2352,7 +2441,7 @@ function BulkSelectionBar({
<button
type="button"
onClick={onDeleteSelected}
className="flex items-center gap-2 rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
className="flex items-center gap-2 whitespace-nowrap rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
>
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
Delete
@@ -14,6 +14,9 @@ interface TagChipInputProps {
suggestions: TagSuggestion[];
/** Read-only mode: chips render without remove affordance or input. */
disabled?: boolean;
/** Open the suggestion dropdown above the input instead of below — for
* hosts anchored to the bottom of the viewport (e.g. the bulk action bar). */
dropUp?: boolean;
onAdd: (values: string[]) => void;
onRemove: (value: string) => void;
}
@@ -30,6 +33,7 @@ export function TagChipInput({
chips,
suggestions,
disabled,
dropUp,
onAdd,
onRemove,
}: TagChipInputProps) {
@@ -187,7 +191,11 @@ export function TagChipInput({
</div>
{dropdownOpen && (
<div className="absolute left-0 right-0 top-full z-30 mt-1 max-h-48 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl">
<div
className={`absolute left-0 right-0 z-30 max-h-48 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl ${
dropUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{filtered.map((suggestion, index) => (
<button
key={suggestion.value.toLowerCase()}
@@ -95,6 +95,7 @@ export const getModelDisplayName = (
seedance_2p0_bpu: "Seedance 2.0 Plus Ultra",
seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast",
seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini",
seedance_2p5: "Seedance 2.5",
seedance_2p5_preview: "Seedance 2.5 Preview",
// Seedance — dot-normalized aliases
seedance_1_0_lite: "Seedance 1.0 Lite",