mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9653a1d21 | ||
|
|
42c867cfea |
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user