Compare commits

...
4 changed files with 121 additions and 81 deletions
@@ -473,6 +473,8 @@ function formatTitleParts(job: Job) {
kind = "Video";
} else if (taskTypeStr.includes("image")) {
kind = "Image";
} else if (taskTypeStr.includes("character")) {
kind = "Character";
}
const modelDisplay = modelTypeStr
@@ -631,6 +633,10 @@ export const TaskQueue = () => {
const [lightboxCdnUrl, setLightboxCdnUrl] = useState<string | undefined>();
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
const prevFailedIdsRef = useRef<Set<string>>(new Set());
// On first load we seed the "seen" sets with whatever is already on the
// server so we don't blast the user with toasts for jobs completed during
// a previous session.
const initialLoadDoneRef = useRef(false);
const [confirmationConfig, setConfirmationConfig] = useState<{
isOpen: boolean;
@@ -752,40 +758,46 @@ export const TaskQueue = () => {
// Track newly completed IDs
const newCompletedIdSet = new Set(done.map((d) => d.id));
const newlyCompleted = done.filter(
(d) => !prevCompletedIdsRef.current.has(d.id),
);
prevCompletedIdsRef.current = newCompletedIdSet;
if (newlyCompleted.length > 0) {
// Show toasts for newly completed jobs
for (const task of newlyCompleted) {
showToast(
"success",
`${task.title} complete${task.subtitle ? ` — ${task.subtitle}` : ""}`,
);
}
if (!isPopoverOpen) {
setUnreadCompletedIds((prev) =>
Array.from(
new Set([...(prev ?? []), ...newlyCompleted.map((d) => d.id)]),
),
);
}
}
// Track newly failed IDs and show toasts
const newFailedIdSet = new Set(failedTasks.map((f) => f.id));
const newlyFailed = failedTasks.filter(
(f) => !prevFailedIdsRef.current.has(f.id),
);
prevFailedIdsRef.current = newFailedIdSet;
for (const task of newlyFailed) {
showToast(
"error",
`${task.title} failed${task.failureReason ? ` — ${task.failureReason}` : ""}`,
if (!initialLoadDoneRef.current) {
// First load: seed the "seen" sets without toasting.
prevCompletedIdsRef.current = newCompletedIdSet;
prevFailedIdsRef.current = newFailedIdSet;
initialLoadDoneRef.current = true;
} else {
const newlyCompleted = done.filter(
(d) => !prevCompletedIdsRef.current.has(d.id),
);
prevCompletedIdsRef.current = newCompletedIdSet;
if (newlyCompleted.length > 0) {
for (const task of newlyCompleted) {
showToast("success", `${task.title} creation complete`);
}
if (!isPopoverOpen) {
setUnreadCompletedIds((prev) =>
Array.from(
new Set([
...(prev ?? []),
...newlyCompleted.map((d) => d.id),
]),
),
);
}
}
const newlyFailed = failedTasks.filter(
(f) => !prevFailedIdsRef.current.has(f.id),
);
prevFailedIdsRef.current = newFailedIdSet;
for (const task of newlyFailed) {
showToast(
"error",
`${task.title} creation failed${task.failureReason ? ` — ${task.failureReason}` : ""}`,
);
}
}
} catch {
// ignore
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faCircleExclamation,
faExclamation,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
@@ -27,11 +27,7 @@ interface ToastDetail {
duration?: number;
}
export function showToast(
type: ToastType,
message: string,
duration?: number,
) {
export function showToast(type: ToastType, message: string, duration?: number) {
window.dispatchEvent(
new CustomEvent<ToastDetail>(TOAST_EVENT, {
detail: { type, message, duration },
@@ -57,8 +53,7 @@ export const ToastContainer = () => {
setToasts((prev) => {
if (
prev.some(
(t) =>
t.message === message && now - t.createdAt < DEDUP_WINDOW,
(t) => t.message === message && now - t.createdAt < DEDUP_WINDOW,
)
)
return prev;
@@ -86,23 +81,25 @@ export const ToastContainer = () => {
if (toasts.length === 0) return null;
return (
<div className="fixed top-16 right-6 z-[100] flex flex-col items-end gap-2">
<div className="fixed sm:top-16 top-14 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-5 z-[100] flex flex-col items-center sm:items-end gap-2 px-4 sm:px-0 max-w-[calc(100vw-1rem)]">
{toasts.map((toast) => (
<div
key={toast.id}
className={`flex items-center gap-2.5 rounded-lg border px-4 py-2.5 text-sm shadow-lg backdrop-blur-xl animate-toast-in ${
toast.type === "success"
? "border-green-500/30 bg-green-500/15 text-green-200"
: "border-red-500/30 bg-red-500/15 text-red-200"
}`}
className="glass flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-white animate-toast-in"
>
<FontAwesomeIcon
icon={toast.type === "success" ? faCheck : faCircleExclamation}
className={
toast.type === "success" ? "text-green-400" : "text-red-400"
}
/>
<span>{toast.message}</span>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full ${
toast.type === "success" ? "bg-green-500" : "bg-red-500"
}`}
>
<FontAwesomeIcon
icon={toast.type === "success" ? faCheck : faExclamation}
className="h-3 w-3 text-white"
/>
</div>
<span className="flex-1 text-white/90 whitespace-nowrap overflow-hidden text-ellipsis">
{toast.message}
</span>
<button
onClick={() => dismiss(toast.id)}
className="ml-1 text-white/40 transition-colors hover:text-white/80"
+3 -1
View File
@@ -14,7 +14,9 @@ const root = ReactDOM.createRoot(
// In development, route API through the Vite dev server origin to avoid CORS
if (import.meta.env.DEV) {
try {
StorytellerApiHostStore.getInstance().setDevelopment();
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
window.location.origin,
);
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -153,9 +153,7 @@ function resolveDurationForModel(
}
if (model.duration_seconds_options?.length) {
if (model.duration_seconds_options.includes(current)) return current;
return (
model.duration_seconds_default ?? model.duration_seconds_options[0]!
);
return model.duration_seconds_default ?? model.duration_seconds_options[0]!;
}
return model.duration_seconds_default ?? null;
}
@@ -310,9 +308,12 @@ export default function CreateVideo() {
generateAudio: hasSound ? generateWithSound : undefined,
});
// Characters are only supported for seedance_2p0
const isSeedance2p0 = selectedModel?.model === "seedance_2p0";
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
// Characters are supported for Seedance 2.0 and Seedance 2.0 Fast, in
// both keyframe and reference input modes.
const supportsCharacters =
selectedModel?.model === "seedance_2p0" ||
selectedModel?.model === "seedance_2p0_fast";
const activeCharacters = supportsCharacters ? storedCharacters : [];
// Popover items
const mentionItems = useMemo((): MentionItem[] => {
@@ -475,38 +476,49 @@ export default function CreateVideo() {
(item: PopoverItem) => {
const model = item.action ? _modelLookup.get(item.action) : undefined;
if (!model) return;
const currentDuration = useCreateVideoStore.getState().ui.duration;
const nextDuration = resolveDurationForModel(model, currentDuration);
const currentState = useCreateVideoStore.getState().ui;
const nextDuration = resolveDurationForModel(
model,
currentState.duration,
);
const newSupportsKeyframe =
!!model.starting_keyframe_supported ||
!!model.starting_keyframe_required;
const newSupportsRefs =
!!model.image_references_supported ||
!!model.video_references_supported ||
!!model.audio_references_supported;
const nextInputMode =
currentState.inputMode === "reference" && newSupportsRefs
? "reference"
: "keyframe";
setUi({
selectedModelId: model.model,
selectedSize: model.aspect_ratio_default ?? "wide_sixteen_by_nine",
duration: nextDuration,
resolution: model.resolution_default ?? null,
generateWithSound: false,
inputMode: "keyframe",
inputMode: nextInputMode,
numVideos: Math.min(
model.batch_size_max ?? 4,
model.batch_size_default ?? 1,
),
});
// Only clear media that the new model doesn't support
const newSupportsKeyframe =
!!model.starting_keyframe_supported ||
!!model.starting_keyframe_required;
if (!newSupportsKeyframe) {
// Only clear media that the new model can't use in any mode.
if (!newSupportsKeyframe && !model.image_references_supported) {
setReferenceImages([]);
}
if (!model.ending_keyframe_supported) {
setEndFrameImage(undefined);
}
const newSupportsRefs =
!!model.image_references_supported ||
!!model.video_references_supported ||
!!model.audio_references_supported;
if (!newSupportsRefs) {
if (!model.video_references_supported) {
setReferenceVideos([]);
}
if (!model.audio_references_supported) {
setReferenceAudios([]);
}
},
@@ -601,10 +613,23 @@ export default function CreateVideo() {
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
: undefined;
// Extract character tokens from @-mentions in prompt
const mentionedCharacters = activeCharacters.filter((c) =>
prompt.includes(`@${c.name}`),
);
// Extract character tokens from @-mentions in the prompt. Match longest
// names first and require a non-word boundary after so `@Bob` doesn't
// false-match inside `@Bobby`, and only pick up characters that still
// exist in the current store (stale names are ignored).
const mentionedCharacters = (() => {
if (activeCharacters.length === 0) return [];
const sorted = [...activeCharacters].sort(
(a, b) => b.name.length - a.name.length,
);
const matched = new Set<string>();
for (const c of sorted) {
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`@${escaped}(?![\\w])`);
if (regex.test(prompt)) matched.add(c.character_token);
}
return activeCharacters.filter((c) => matched.has(c.character_token));
})();
const referenceCharacterTokens =
mentionedCharacters.length > 0
? mentionedCharacters.map((c) => c.character_token)
@@ -615,8 +640,7 @@ export default function CreateVideo() {
model: selectedModel.model,
numVideos: 1,
aspectRatio: selectedSize,
duration:
duration ?? selectedModel.duration_seconds_default ?? undefined,
duration: duration ?? selectedModel.duration_seconds_default ?? undefined,
resolution: hasResolutionOptions
? (resolution ?? selectedModel.resolution_default ?? undefined)
: undefined,
@@ -786,7 +810,12 @@ export default function CreateVideo() {
hasEndFrame ? () => setIsEndFramePickerOpen(true) : undefined
}
modelSelector={
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<Tooltip
content="Model"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
@@ -954,7 +983,7 @@ export default function CreateVideo() {
/>
</Tooltip>
)}
{isSeedance2p0 && (
{supportsCharacters && (
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}