mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
characters (#1280)
- Create, list, delete characters for use as reference with seedance 2.0 - Characters are mentionable in the prompt box by using the @ sign
This commit is contained in:
@@ -23,5 +23,7 @@ export * from "./lib/WeightsApi.js";
|
||||
export * from "./lib/GetCdnOrigin.js";
|
||||
export * from "./lib/GalleryModalApi.js";
|
||||
export * from "./lib/OmniGenApi.js";
|
||||
export * from "./lib/CharactersApi.js";
|
||||
export * from "./lib/models/Character.js";
|
||||
export * from "./lib/config/StorytellerApiHostStore.js";
|
||||
export * from "./lib/enums/EIntermediateFile.js";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager.js";
|
||||
import { Character } from "./models/Character.js";
|
||||
|
||||
export interface CreateCharacterRequest {
|
||||
image_media_token: string;
|
||||
model: string;
|
||||
uuid_idempotency_token: string;
|
||||
character_name: string;
|
||||
character_description?: string | null;
|
||||
}
|
||||
|
||||
export interface EditCharacterRequest {
|
||||
token: string;
|
||||
updated_name?: string | null;
|
||||
updated_description?: string | null;
|
||||
}
|
||||
|
||||
export interface ListCharactersRequest {
|
||||
cursor?: number;
|
||||
}
|
||||
|
||||
export class CharactersApi extends ApiManager {
|
||||
public CreateCharacter(
|
||||
params: CreateCharacterRequest,
|
||||
): Promise<ApiResponse<{ inference_job_token: string }>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/create`;
|
||||
|
||||
return this.post<
|
||||
CreateCharacterRequest,
|
||||
{ success: boolean; inference_job_token: string }
|
||||
>({
|
||||
endpoint,
|
||||
body: params,
|
||||
})
|
||||
.then(({ success, inference_job_token }) => ({
|
||||
success,
|
||||
data: { inference_job_token },
|
||||
}))
|
||||
.catch((err) => ({
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
}));
|
||||
}
|
||||
|
||||
public EditCharacter(
|
||||
params: EditCharacterRequest,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/edit`;
|
||||
|
||||
return this.post<
|
||||
EditCharacterRequest,
|
||||
{ success: boolean; BadInput?: string }
|
||||
>({
|
||||
endpoint,
|
||||
body: params,
|
||||
})
|
||||
.then(({ success, BadInput }) => ({
|
||||
success: success ?? false,
|
||||
errorMessage: BadInput,
|
||||
}))
|
||||
.catch((err) => ({
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
}));
|
||||
}
|
||||
|
||||
public GetCharacter({
|
||||
characterToken,
|
||||
}: {
|
||||
characterToken: string;
|
||||
}): Promise<ApiResponse<Character>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/${characterToken}`;
|
||||
|
||||
return this.get<{ success: boolean; character: Character }>({ endpoint })
|
||||
.then(({ success, character }) => ({
|
||||
success,
|
||||
data: character,
|
||||
}))
|
||||
.catch((err) => ({
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
}));
|
||||
}
|
||||
|
||||
public DeleteCharacter({
|
||||
characterToken,
|
||||
}: {
|
||||
characterToken: string;
|
||||
}): Promise<ApiResponse<undefined>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/${characterToken}`;
|
||||
|
||||
return this.delete<null, { success: boolean }>({
|
||||
endpoint,
|
||||
})
|
||||
.then(({ success }) => ({
|
||||
success: success ?? false,
|
||||
}))
|
||||
.catch((err) => ({
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
}));
|
||||
}
|
||||
|
||||
public ListCharacters(
|
||||
params?: ListCharactersRequest,
|
||||
): Promise<ApiResponse<Character[], { next_cursor?: number | null }>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/session`;
|
||||
|
||||
const query = params?.cursor
|
||||
? { cursor: params.cursor.toString() }
|
||||
: undefined;
|
||||
|
||||
return this.get<{
|
||||
success: boolean;
|
||||
characters: Character[];
|
||||
next_cursor?: number | null;
|
||||
}>({ endpoint, query })
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.characters,
|
||||
pagination: { next_cursor: response.next_cursor },
|
||||
}))
|
||||
.catch((err) => ({
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface MediaFileLinks {
|
||||
cdn_url: string;
|
||||
maybe_thumbnail_template?: string | null;
|
||||
maybe_video_previews?: {
|
||||
cdn_url: string;
|
||||
maybe_thumbnail_template?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
token: string;
|
||||
name: string;
|
||||
maybe_description?: string | null;
|
||||
maybe_avatar?: MediaFileLinks | null;
|
||||
maybe_full_image?: MediaFileLinks | null;
|
||||
models: string[];
|
||||
is_user_created?: boolean;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const Label = ({
|
||||
...rest
|
||||
}: LabelProps) => (
|
||||
<label
|
||||
className={twMerge("text-base-fg mb-2 text-[15px] font-medium", className)}
|
||||
className={twMerge("text-base-fg mb-1.5 text-[15px] font-medium", className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faPlus,
|
||||
faArrowLeft,
|
||||
faUpload,
|
||||
faUserGroup,
|
||||
faSpinnerThird,
|
||||
faImages,
|
||||
faXmark,
|
||||
faPen,
|
||||
faTrashAlt,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
CharactersApi,
|
||||
Character,
|
||||
MediaUploadApi,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { useCharactersStore } from "./promptStore";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Label } from "@storyteller/ui-label";
|
||||
|
||||
interface CharactersModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectCharacter?: (character: Character) => void;
|
||||
}
|
||||
|
||||
type ModalView = "list" | "create" | "edit";
|
||||
|
||||
interface UploadedImage {
|
||||
file: File;
|
||||
url: string;
|
||||
mediaToken?: string;
|
||||
}
|
||||
|
||||
interface PendingCharacter {
|
||||
name: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export const CharactersModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectCharacter,
|
||||
}: CharactersModalProps) => {
|
||||
const [view, setView] = useState<ModalView>("list");
|
||||
const [editingCharacter, setEditingCharacter] = useState<Character | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingCharacters, setPendingCharacters] = useState<
|
||||
PendingCharacter[]
|
||||
>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleClose = () => {
|
||||
setView("list");
|
||||
setEditingCharacter(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEdit = (character: Character) => {
|
||||
setEditingCharacter(character);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleEditDone = () => {
|
||||
setEditingCharacter(null);
|
||||
setView("list");
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const handleCreated = (pending: PendingCharacter) => {
|
||||
setPendingCharacters((prev) => [pending, ...prev]);
|
||||
setView("list");
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const removePending = useCallback((name: string) => {
|
||||
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={view === "list" ? "Characters" : undefined}
|
||||
className="max-w-[800px] h-[60vh] max-h-[600px] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{view === "list" ? (
|
||||
<CharacterListView
|
||||
key={refreshKey}
|
||||
onCreateClick={() => setView("create")}
|
||||
onSelectCharacter={onSelectCharacter}
|
||||
onEditCharacter={handleEdit}
|
||||
pendingCharacters={pendingCharacters}
|
||||
onPendingResolved={removePending}
|
||||
/>
|
||||
) : view === "create" ? (
|
||||
<NewCharacterView
|
||||
onBack={() => setView("list")}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
) : editingCharacter ? (
|
||||
<EditCharacterView
|
||||
character={editingCharacter}
|
||||
onBack={() => {
|
||||
setEditingCharacter(null);
|
||||
setView("list");
|
||||
}}
|
||||
onSaved={handleEditDone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Character List View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CharacterListView = ({
|
||||
onCreateClick,
|
||||
onSelectCharacter,
|
||||
onEditCharacter,
|
||||
pendingCharacters,
|
||||
onPendingResolved,
|
||||
}: {
|
||||
onCreateClick: () => void;
|
||||
onSelectCharacter?: (character: Character) => void;
|
||||
onEditCharacter: (character: Character) => void;
|
||||
pendingCharacters: PendingCharacter[];
|
||||
onPendingResolved: (name: string) => void;
|
||||
}) => {
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState<number | undefined>(undefined);
|
||||
const [deletingToken, setDeletingToken] = useState<string | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
|
||||
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
|
||||
const storeRemoveCharacter = useCharactersStore((s) => s.removeCharacter);
|
||||
|
||||
const syncToStore = useCallback(
|
||||
(chars: Character[]) => {
|
||||
storeSetCharacters(
|
||||
chars.map((c) => ({
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
})),
|
||||
);
|
||||
storeSetLoaded(true);
|
||||
},
|
||||
[storeSetCharacters, storeSetLoaded],
|
||||
);
|
||||
|
||||
const fetchCharacters = useCallback(
|
||||
async (nextCursor?: number) => {
|
||||
if (loadingMoreRef.current) return;
|
||||
loadingMoreRef.current = true;
|
||||
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.ListCharacters({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
setCharacters((prev) => {
|
||||
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
|
||||
syncToStore(updated);
|
||||
return updated;
|
||||
});
|
||||
const nextPage = res.pagination?.next_cursor;
|
||||
setCursor(nextPage ?? undefined);
|
||||
setHasMore(!!nextPage);
|
||||
}
|
||||
} catch {
|
||||
storeSetLoaded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingMoreRef.current = false;
|
||||
}
|
||||
},
|
||||
[syncToStore, storeSetLoaded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
}, [fetchCharacters]);
|
||||
|
||||
// Poll for pending characters becoming active
|
||||
useEffect(() => {
|
||||
if (pendingCharacters.length === 0) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.ListCharacters({});
|
||||
if (res.success && res.data) {
|
||||
const serverNames = new Set(res.data.map((c) => c.name));
|
||||
// Resolve any pending characters that now appear in the server list
|
||||
for (const pending of pendingCharacters) {
|
||||
if (serverNames.has(pending.name)) {
|
||||
onPendingResolved(pending.name);
|
||||
}
|
||||
}
|
||||
// Update the list with fresh server data
|
||||
setCharacters(res.data);
|
||||
syncToStore(res.data);
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next interval
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingCharacters, onPendingResolved, syncToStore]);
|
||||
|
||||
// Infinite scroll via IntersectionObserver
|
||||
useEffect(() => {
|
||||
if (!sentinelRef.current || !hasMore) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasMore && cursor) {
|
||||
fetchCharacters(cursor);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, cursor, fetchCharacters]);
|
||||
|
||||
const handleDelete = async (character: Character) => {
|
||||
setDeletingToken(character.token);
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.DeleteCharacter({
|
||||
characterToken: character.token,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setCharacters((prev) =>
|
||||
prev.filter((c) => c.token !== character.token),
|
||||
);
|
||||
storeRemoveCharacter(character.token);
|
||||
toast.success(`Character "${character.name}" deleted`);
|
||||
} else {
|
||||
toast.error(res.errorMessage || "Failed to delete character");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete character");
|
||||
} finally {
|
||||
setDeletingToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[50vh] flex-col overflow-y-auto">
|
||||
{loading && characters.length === 0 ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col overflow-hidden rounded-lg border border-transparent bg-base-fg/[0.05]"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full w-full bg-base-fg/[0.06]"
|
||||
style={{
|
||||
animation: `charPulse 1.8s ease-in-out ${i * 0.07}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex justify-center bg-base-fg/[0.04]">
|
||||
<div
|
||||
className="h-3 w-2/3 rounded bg-base-fg/[0.08]"
|
||||
style={{
|
||||
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes charPulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{/* Create New card */}
|
||||
<button
|
||||
onClick={onCreateClick}
|
||||
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-base-fg/10 bg-base-fg/[0.05] text-base-fg/60 transition-colors hover:border-base-fg/25 hover:text-base-fg/80"
|
||||
>
|
||||
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon icon={faPlus} className="text-lg" />
|
||||
<span className="text-sm font-medium">Create New</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pending (creating) characters */}
|
||||
{pendingCharacters.map((pending) => (
|
||||
<div
|
||||
key={`pending-${pending.name}`}
|
||||
className="relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-base-fg/[0.05]"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden bg-base-fg/[0.05]">
|
||||
{pending.previewUrl ? (
|
||||
<img
|
||||
src={pending.previewUrl}
|
||||
alt={pending.name}
|
||||
className="h-full w-full object-cover object-top opacity-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-base-fg/20">
|
||||
<FontAwesomeIcon icon={faUserGroup} className="text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-lg text-white/80 animate-spin"
|
||||
/>
|
||||
<span className="text-xs font-medium text-white/80">
|
||||
Creating...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 text-center">
|
||||
<p className="truncate text-xs font-medium text-base-fg/50">
|
||||
{pending.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{characters.map((character) => {
|
||||
const isUserCreated = character.is_user_created !== false;
|
||||
const isDeleting = deletingToken === character.token;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={character.token}
|
||||
className="group relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-base-fg/[0.05] transition-colors hover:border-base-fg/25 hover:bg-base-fg/10"
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelectCharacter?.(character)}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden bg-base-fg/[0.05]">
|
||||
{character.maybe_avatar?.cdn_url ? (
|
||||
<img
|
||||
src={character.maybe_avatar.cdn_url}
|
||||
alt={character.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-base-fg/20">
|
||||
<FontAwesomeIcon
|
||||
icon={faUserGroup}
|
||||
className="text-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-xs font-medium text-base-fg/80">
|
||||
{character.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Edit / Delete overlay buttons (user-created only) */}
|
||||
{isUserCreated && (
|
||||
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditCharacter(character);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} className="text-[10px]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(character);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-[10px] animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashAlt}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sentinel for infinite scroll */}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-4">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-base-fg/30 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Character View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EditCharacterView = ({
|
||||
character,
|
||||
onBack,
|
||||
onSaved,
|
||||
}: {
|
||||
character: Character;
|
||||
onBack: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const updateCharacterInStore = useCharactersStore((s) => s.updateCharacter);
|
||||
const [name, setName] = useState(character.name);
|
||||
const [description, setDescription] = useState(
|
||||
character.maybe_description ?? "",
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const hasChanges =
|
||||
name.trim() !== character.name ||
|
||||
(description.trim() || "") !== (character.maybe_description ?? "");
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Name cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.EditCharacter({
|
||||
token: character.token,
|
||||
updated_name: name.trim() !== character.name ? name.trim() : undefined,
|
||||
updated_description:
|
||||
(description.trim() || "") !== (character.maybe_description ?? "")
|
||||
? description.trim() || null
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
toast.success("Character updated");
|
||||
updateCharacterInStore(character.token, { name: name.trim() });
|
||||
onSaved();
|
||||
} else {
|
||||
toast.error(res.errorMessage || "Failed to update character");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update character");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center justify-center text-base-fg/60 transition-colors hover:text-base-fg"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-base-fg">Edit Character</h2>
|
||||
</div>
|
||||
|
||||
{/* Avatar preview */}
|
||||
{character.maybe_avatar?.cdn_url && (
|
||||
<div className="flex h-56 max-h-56 items-center justify-center overflow-hidden rounded-lg bg-base-fg/[0.05]">
|
||||
<img
|
||||
src={character.maybe_avatar.cdn_url}
|
||||
alt={character.name}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name input */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="edit-character-name">Name</Label>
|
||||
<Input
|
||||
id="edit-character-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Character name"
|
||||
autoComplete="off"
|
||||
inputClassName="bg-base-fg/[0.07] hover:border-ui-panel-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description input */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="edit-character-description">
|
||||
Description{" "}
|
||||
<span className="font-normal text-base-fg/40">(optional)</span>
|
||||
</Label>
|
||||
<textarea
|
||||
id="edit-character-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe this character..."
|
||||
rows={3}
|
||||
autoComplete="off"
|
||||
className="w-full resize-none rounded-lg px-3 py-2 outline-none bg-base-fg/[0.07] text-base-fg placeholder-base-fg/50 border border-ui-panel-border transition-all duration-150 ease-in-out focus:border-primary focus:!outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving || !name.trim() || !hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New Character View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NewCharacterView = ({
|
||||
onBack,
|
||||
onCreated,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onCreated: (pending: PendingCharacter) => void;
|
||||
}) => {
|
||||
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
const imageFiles = Array.from(files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
);
|
||||
if (imageFiles.length === 0) {
|
||||
toast.error("Please upload image files");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only keep the first file (single image)
|
||||
const file = imageFiles[0]!;
|
||||
const newImages: UploadedImage[] = [
|
||||
{ file, url: URL.createObjectURL(file) },
|
||||
];
|
||||
|
||||
// Replace any existing image
|
||||
setImages((prev) => {
|
||||
prev.forEach((img) => URL.revokeObjectURL(img.url));
|
||||
return newImages;
|
||||
});
|
||||
|
||||
// Upload each image to get media tokens
|
||||
setUploading(true);
|
||||
const uploadApi = new MediaUploadApi();
|
||||
const updatedImages: UploadedImage[] = [];
|
||||
|
||||
for (const img of newImages) {
|
||||
try {
|
||||
const res = await uploadApi.UploadImage({
|
||||
uuid: uuidv4(),
|
||||
blob: img.file,
|
||||
fileName: img.file.name,
|
||||
maybe_title: `character_ref_${name || "unnamed"}`,
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
updatedImages.push({ ...img, mediaToken: res.data });
|
||||
} else {
|
||||
toast.error(`Failed to upload ${img.file.name}`);
|
||||
updatedImages.push(img);
|
||||
}
|
||||
} catch {
|
||||
toast.error(`Failed to upload ${img.file.name}`);
|
||||
updatedImages.push(img);
|
||||
}
|
||||
}
|
||||
|
||||
setImages((prev) =>
|
||||
prev.map((existing) => {
|
||||
const updated = updatedImages.find((u) => u.url === existing.url);
|
||||
return updated || existing;
|
||||
}),
|
||||
);
|
||||
setUploading(false);
|
||||
},
|
||||
[name],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging) setIsDragging(true);
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[isDragging],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const { clientX: x, clientY: y } = e;
|
||||
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files?.length) {
|
||||
processFiles(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.length) {
|
||||
processFiles(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImages((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed) URL.revokeObjectURL(removed.url);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Please enter a character name");
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedImages = images.filter((img) => img.mediaToken);
|
||||
if (uploadedImages.length < 1) {
|
||||
toast.error("Please upload a reference image");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.CreateCharacter({
|
||||
image_media_token: uploadedImages[0]!.mediaToken!,
|
||||
model: "seedance_2p0",
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
character_name: name.trim(),
|
||||
character_description: description.trim() || null,
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
toast.success(`Character "${name.trim()}" is being created`);
|
||||
// Add to global store so it appears in @-mentions immediately
|
||||
addCharacterToStore({
|
||||
character_token: res.data.inference_job_token,
|
||||
name: name.trim(),
|
||||
avatar_image_url: uploadedImages[0]!.url,
|
||||
});
|
||||
// Pass pending info up so the list shows an optimistic card
|
||||
onCreated({
|
||||
name: name.trim(),
|
||||
previewUrl: uploadedImages[0]!.url,
|
||||
});
|
||||
} else {
|
||||
toast.error(res.errorMessage || "Failed to create character");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create character");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup object URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
images.forEach((img) => URL.revokeObjectURL(img.url));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center gap-3 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center justify-center text-base-fg/60 transition-colors hover:text-base-fg"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-base-fg">New Character</h2>
|
||||
</div>
|
||||
|
||||
{/* Image upload area */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={twMerge(
|
||||
"flex h-56 max-h-56 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-base-fg/20 bg-base-fg/[0.05] transition-colors overflow-hidden",
|
||||
isDragging && "border-blue-400 bg-blue-500/10",
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{images.length > 0 ? (
|
||||
<div
|
||||
className="group relative flex h-full w-full items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={images[0]!.url}
|
||||
alt="Reference"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
{!images[0]!.mediaToken && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-white animate-spin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(0);
|
||||
}}
|
||||
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center text-base-fg/60">
|
||||
<FontAwesomeIcon
|
||||
icon={faUpload}
|
||||
className="mb-2 text-xl text-base-fg/40"
|
||||
/>
|
||||
<p className="text-sm">Upload reference image</p>
|
||||
<p className="mb-3 text-xs text-base-fg/40">
|
||||
Click or drag an image here
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsGalleryOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-base-fg/10 px-3 py-1.5 text-sm text-base-fg/80 transition-colors hover:bg-base-fg/20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faImages} className="text-xs" />
|
||||
Choose from Library
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2 rounded-lg bg-base-fg/10 px-3 py-1.5 text-sm text-base-fg/80 transition-colors hover:bg-base-fg/20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpload} className="text-xs" />
|
||||
Upload Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Name input */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="character-name">Name</Label>
|
||||
<Input
|
||||
id="character-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Character name"
|
||||
autoComplete="off"
|
||||
inputClassName="bg-base-fg/[0.07] hover:border-ui-panel-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description input */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="character-description">
|
||||
Description{" "}
|
||||
<span className="font-normal text-base-fg/40">(optional)</span>
|
||||
</Label>
|
||||
<textarea
|
||||
id="character-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe this character..."
|
||||
rows={3}
|
||||
autoComplete="off"
|
||||
className="w-full resize-none rounded-lg px-3 py-2 outline-none bg-base-fg/[0.07] text-base-fg placeholder-base-fg/50 border border-ui-panel-border transition-all duration-150 ease-in-out focus:border-primary focus:!outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
loading={creating}
|
||||
disabled={
|
||||
creating ||
|
||||
uploading ||
|
||||
!name.trim() ||
|
||||
images.filter((i) => i.mediaToken).length < 1
|
||||
}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GalleryModal
|
||||
isOpen={isGalleryOpen}
|
||||
onClose={() => {
|
||||
setIsGalleryOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
}}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
onSelectItem={(id) => {
|
||||
setSelectedGalleryImages((prev) => (prev.includes(id) ? [] : [id]));
|
||||
}}
|
||||
maxSelections={1}
|
||||
onUseSelected={(selectedItems: GalleryItem[]) => {
|
||||
const item = selectedItems[0];
|
||||
if (item && item.fullImage) {
|
||||
// Clean up previous image
|
||||
images.forEach((img) => URL.revokeObjectURL(img.url));
|
||||
setImages([
|
||||
{
|
||||
file: new File([], "library-image"),
|
||||
url: item.fullImage,
|
||||
mediaToken: item.id,
|
||||
},
|
||||
]);
|
||||
}
|
||||
setIsGalleryOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
}}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
SizeOption,
|
||||
VideoModel,
|
||||
} from "@storyteller/model-list";
|
||||
import { usePromptVideoStore, RefImage, VideoInputMode } from "./promptStore";
|
||||
import {
|
||||
usePromptVideoStore,
|
||||
RefImage,
|
||||
VideoInputMode,
|
||||
useCharactersStore,
|
||||
} from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import type { UploadImageFn } from "./ImagePromptRow";
|
||||
@@ -39,6 +44,9 @@ import { VideoGenerationCountPicker } from "./common/VideoGenerationCountPicker"
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { CharactersModal } from "./CharactersModal";
|
||||
import { CharactersApi } from "@storyteller/api";
|
||||
import { faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -132,6 +140,34 @@ export const PromptBoxVideo = ({
|
||||
const [isEnqueueing, setIsEnqueueing] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
|
||||
|
||||
// Characters store for @-mentions
|
||||
const storedCharacters = useCharactersStore((s) => s.characters);
|
||||
const charactersLoaded = useCharactersStore((s) => s.loaded);
|
||||
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
|
||||
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
|
||||
|
||||
// Load characters on mount if not already loaded
|
||||
useEffect(() => {
|
||||
if (charactersLoaded) return;
|
||||
const api = new CharactersApi();
|
||||
api
|
||||
.ListCharacters()
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
storeSetCharacters(
|
||||
res.data.map((c) => ({
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => storeSetLoaded(true));
|
||||
}, [charactersLoaded, storeSetCharacters, storeSetLoaded]);
|
||||
|
||||
// CSS viewport units handle resize reactivity automatically
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
@@ -380,15 +416,39 @@ export const PromptBoxVideo = ({
|
||||
"rgb(192, 132, 252)", // violet
|
||||
"rgb(232, 121, 249)", // fuchsia
|
||||
];
|
||||
const CHARACTER_COLORS = [
|
||||
"rgb(45, 212, 191)", // teal
|
||||
"rgb(34, 197, 94)", // emerald
|
||||
"rgb(14, 165, 233)", // sky
|
||||
];
|
||||
|
||||
const hasAnyRefs =
|
||||
referenceImages.length > 0 ||
|
||||
referenceVideos.length > 0 ||
|
||||
referenceAudios.length > 0;
|
||||
|
||||
// Characters are only supported for seedance_2p0
|
||||
const isSeedance2p0 = selectedModel?.id === "seedance_2p0";
|
||||
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
|
||||
|
||||
// Build a set of character names for highlight matching
|
||||
const characterNames = activeCharacters.map((c) => c.name);
|
||||
|
||||
const hasAnyMentionables = hasAnyRefs || activeCharacters.length > 0;
|
||||
|
||||
const renderHighlightedPrompt = () => {
|
||||
if (!isReferenceMode || !hasAnyRefs) return null;
|
||||
const parts = prompt.split(/(@(?:Image|Video|Audio)\d+)/g);
|
||||
if (!hasAnyMentionables) return null;
|
||||
// Build regex that matches @Image1, @Video1, @Audio1 and @CharacterName
|
||||
const charPattern = characterNames
|
||||
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|");
|
||||
const refPattern = "@(?:Image|Video|Audio)\\d+";
|
||||
const fullPattern = charPattern
|
||||
? `(${refPattern}|@(?:${charPattern}))`
|
||||
: `(${refPattern})`;
|
||||
const regex = new RegExp(fullPattern, "g");
|
||||
const parts = prompt.split(regex);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
const imgMatch = part.match(/^@Image(\d+)$/);
|
||||
if (imgMatch) {
|
||||
@@ -426,6 +486,23 @@ export const PromptBoxVideo = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Check character match
|
||||
if (part.startsWith("@")) {
|
||||
const charName = part.slice(1);
|
||||
const charIdx = characterNames.indexOf(charName);
|
||||
if (charIdx !== -1) {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: CHARACTER_COLORS[charIdx % CHARACTER_COLORS.length],
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
@@ -436,29 +513,36 @@ export const PromptBoxVideo = ({
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const mentionAnchorRef = useRef<number | null>(null);
|
||||
|
||||
const mentionItems = isReferenceMode
|
||||
? [
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined as string | undefined,
|
||||
})),
|
||||
].filter((item) =>
|
||||
mentionFilter
|
||||
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
: [];
|
||||
const mentionItems = [
|
||||
...(isReferenceMode
|
||||
? [
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined as string | undefined,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
...activeCharacters.map((char) => ({
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
})),
|
||||
].filter((item) =>
|
||||
mentionFilter
|
||||
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
|
||||
const insertMention = (label: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
@@ -496,8 +580,8 @@ export const PromptBoxVideo = ({
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setPrompt(value);
|
||||
|
||||
if (isReferenceMode && hasAnyRefs) {
|
||||
// Find the last '@' before cursor that could be a mention trigger
|
||||
// Trigger @-mention for reference files (in reference mode) or characters (always)
|
||||
if ((isReferenceMode && hasAnyRefs) || activeCharacters.length > 0) {
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
@@ -610,6 +694,16 @@ export const PromptBoxVideo = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Extract character tokens from @-mentions in prompt
|
||||
const mentionedCharacters = activeCharacters.filter((c) =>
|
||||
prompt.includes(`@${c.name}`),
|
||||
);
|
||||
if (mentionedCharacters.length > 0) {
|
||||
request.reference_character_tokens = mentionedCharacters.map(
|
||||
(c) => c.character_token,
|
||||
);
|
||||
}
|
||||
|
||||
// Pass duration if model supports it
|
||||
if (selectedModel.durationOptions && duration !== null) {
|
||||
request.duration_seconds = duration;
|
||||
@@ -821,9 +915,9 @@ export const PromptBoxVideo = ({
|
||||
<div className="relative flex justify-center gap-2">
|
||||
{/* @-mention autocomplete dropdown */}
|
||||
{mentionOpen && mentionItems.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 overflow-hidden rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl">
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
||||
Reference Files
|
||||
References
|
||||
</div>
|
||||
{mentionItems.map((item, i) => (
|
||||
<button
|
||||
@@ -839,7 +933,18 @@ export const PromptBoxVideo = ({
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
|
||||
{item.type === "image" && item.preview ? (
|
||||
{item.type === "character" && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="h-3.5 w-3.5 text-base-fg/60"
|
||||
/>
|
||||
) : item.type === "image" && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
@@ -896,7 +1001,7 @@ export const PromptBoxVideo = ({
|
||||
</Tooltip> */}
|
||||
|
||||
<div className="promptbox-resize-wrap relative flex-1">
|
||||
{isReferenceMode && hasAnyRefs && (
|
||||
{hasAnyMentionables && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
@@ -915,7 +1020,7 @@ export const PromptBoxVideo = ({
|
||||
}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
|
||||
isReferenceMode && hasAnyRefs
|
||||
hasAnyMentionables
|
||||
? "text-transparent caret-base-fg"
|
||||
: "text-base-fg",
|
||||
)}
|
||||
@@ -1037,6 +1142,16 @@ export const PromptBoxVideo = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{selectedModel?.id === "seedance_2p0" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCharactersModalOpen(true)}
|
||||
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
|
||||
>
|
||||
@Characters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelNeedsAnImageButNoneAreSelected && (
|
||||
@@ -1109,6 +1224,30 @@ export const PromptBoxVideo = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CharactersModal
|
||||
isOpen={isCharactersModalOpen}
|
||||
onClose={() => setIsCharactersModalOpen(false)}
|
||||
onSelectCharacter={(character) => {
|
||||
const mention = `@${character.name}`;
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart ?? prompt.length;
|
||||
const before = prompt.slice(0, pos);
|
||||
const after = prompt.slice(pos);
|
||||
const spaceBefore =
|
||||
before.length > 0 && !before.endsWith(" ") ? " " : "";
|
||||
setPrompt(before + spaceBefore + mention + " " + after);
|
||||
requestAnimationFrame(() => {
|
||||
const newPos = pos + spaceBefore.length + mention.length + 1;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
});
|
||||
} else {
|
||||
setPrompt(prompt ? prompt + " " + mention + " " : mention + " ");
|
||||
}
|
||||
setIsCharactersModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
<GalleryModal
|
||||
isOpen={!!isGalleryModalOpen}
|
||||
onClose={() => {
|
||||
|
||||
@@ -201,3 +201,39 @@ export const usePromptEditStore = create<PromptEditStore>()((set) => ({
|
||||
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
|
||||
setResolution: (resolution) => set({ resolution }),
|
||||
}));
|
||||
|
||||
// ----- Characters Store -----
|
||||
export interface StoredCharacter {
|
||||
character_token: string;
|
||||
name: string;
|
||||
avatar_image_url?: string;
|
||||
}
|
||||
|
||||
interface CharactersStore {
|
||||
characters: StoredCharacter[];
|
||||
loaded: boolean;
|
||||
setCharacters: (characters: StoredCharacter[]) => void;
|
||||
addCharacter: (character: StoredCharacter) => void;
|
||||
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
|
||||
removeCharacter: (token: string) => void;
|
||||
setLoaded: (loaded: boolean) => void;
|
||||
}
|
||||
|
||||
export const useCharactersStore = create<CharactersStore>()((set) => ({
|
||||
characters: [],
|
||||
loaded: false,
|
||||
setCharacters: (characters) => set({ characters }),
|
||||
addCharacter: (character) =>
|
||||
set((state) => ({ characters: [...state.characters, character] })),
|
||||
updateCharacter: (token, updates) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.map((c) =>
|
||||
c.character_token === token ? { ...c, ...updates } : c,
|
||||
),
|
||||
})),
|
||||
removeCharacter: (token) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.filter((c) => c.character_token !== token),
|
||||
})),
|
||||
setLoaded: (loaded) => set({ loaded }),
|
||||
}));
|
||||
|
||||
@@ -265,7 +265,7 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
creator: ModelCreator.Bytedance,
|
||||
selectorName: "Seedance 2.0",
|
||||
selectorDescription: "High quality model",
|
||||
selectorBadges: ["3 hr."],
|
||||
selectorBadges: ["~15 min."],
|
||||
startFrame: true,
|
||||
endFrame: true,
|
||||
requiresImage: false,
|
||||
@@ -308,7 +308,7 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
resolutionOptions: ["480p", "720p"],
|
||||
defaultResolution: "720p",
|
||||
supportsSystemPrompt: false,
|
||||
progressBarTime: 10800000, //3 hours for now
|
||||
progressBarTime: 900000, // ~15 minutes
|
||||
maxPromptLength: 3000,
|
||||
}),
|
||||
new VideoModel({
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface EnqueueImageToVideoRequest {
|
||||
|
||||
// Optional. Reference audio media tokens (for audio reference).
|
||||
reference_audio_media_tokens?: string[];
|
||||
|
||||
// Optional. Character tokens for character consistency (from @-mentions).
|
||||
reference_character_tokens?: string[];
|
||||
}
|
||||
|
||||
interface RawEnqueueImageToVideoRequest {
|
||||
@@ -78,6 +81,7 @@ interface RawEnqueueImageToVideoRequest {
|
||||
reference_image_media_tokens?: string[];
|
||||
reference_video_media_tokens?: string[];
|
||||
reference_audio_media_tokens?: string[];
|
||||
reference_character_tokens?: string[];
|
||||
}
|
||||
|
||||
export interface EnqueueImageToVideoError extends CommandResult {
|
||||
@@ -168,6 +172,13 @@ export const EnqueueImageToVideo = async (
|
||||
request.reference_audio_media_tokens;
|
||||
}
|
||||
|
||||
if (
|
||||
request.reference_character_tokens &&
|
||||
request.reference_character_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_character_tokens = request.reference_character_tokens;
|
||||
}
|
||||
|
||||
const result = await invoke("enqueue_image_to_video_command", {
|
||||
request: mutableRequest,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user