mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 02:00:46 +00:00
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:
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",
|
||||
|
||||
Reference in new issue
Block a user