1563 lines
116 KiB
TypeScript
1563 lines
116 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react";
|
||
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Trash2, X } from "lucide-react";
|
||
import { Link } from "react-router";
|
||
|
||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||
import { ModelPicker } from "@/components/model-picker";
|
||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||
import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||
import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
|
||
import { createClientId } from "@/lib/client-id";
|
||
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
|
||
import { useCopyText } from "@/hooks/use-copy-text";
|
||
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
|
||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||
import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||
import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image";
|
||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||
import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||
import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
|
||
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
|
||
import { uploadImage } from "@/services/image-storage";
|
||
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
|
||
import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
|
||
import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
|
||
import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
|
||
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||
import { useUserStore } from "@/stores/use-user-store";
|
||
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||
|
||
type CreationMode = "text" | "image" | "video";
|
||
type CreationViewMode = "chat" | "storyboard";
|
||
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
|
||
type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string };
|
||
type CreationRetryContext = GenerationRetryContext & { retryContextsByBatchIndex?: GenerationRetryContext[] };
|
||
type CreationMessage = {
|
||
id: string;
|
||
role: "user" | "assistant";
|
||
mode?: CreationMode;
|
||
content: string;
|
||
createdAt: string;
|
||
status?: CreationStatus;
|
||
model?: string;
|
||
resultUrls?: string[];
|
||
error?: string;
|
||
generationErrorCode?: string;
|
||
generationOperation?: string;
|
||
attachments?: CreationAttachment[];
|
||
references?: CreationReference[];
|
||
settings?: CreationSettings;
|
||
taskIds?: string[];
|
||
clientOperationId?: string;
|
||
retryOf?: string;
|
||
attemptGroupId?: string;
|
||
generationStage?: string;
|
||
generationEffectKeys?: string[];
|
||
};
|
||
type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] };
|
||
|
||
const modeLabels: Record<CreationMode, string> = { text: "文本", image: "图片", video: "视频" };
|
||
const shotScriptLabels: Record<CreationMode, string> = { text: "创作思路", image: "画面指令", video: "镜头脚本" };
|
||
const ratioOptions = [
|
||
{ value: "1:1", label: "方形" },
|
||
{ value: "16:9", label: "横屏" },
|
||
{ value: "9:16", label: "竖屏" },
|
||
{ value: "4:3", label: "标准横屏" },
|
||
{ value: "3:4", label: "标准竖屏" },
|
||
{ value: "21:9", label: "宽银幕" },
|
||
];
|
||
const qualityOptions = [
|
||
{ value: "auto", label: "自动", description: "由模型决定" },
|
||
{ value: "low", label: "低", description: "更快生成" },
|
||
{ value: "medium", label: "中", description: "均衡模式" },
|
||
{ value: "high", label: "高", description: "优先细节" },
|
||
// grok2api / xAI Imagine:quality 映射 resolution
|
||
{ value: "1k", label: "1K", description: "标准清晰度" },
|
||
{ value: "2k", label: "2K", description: "更高清晰度" },
|
||
];
|
||
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
|
||
const countOptions = ["1", "2", "3", "4"];
|
||
const TEXT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
||
const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
||
const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
|
||
function newConversation(): CreationConversation {
|
||
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
|
||
}
|
||
|
||
function newMessage(role: CreationMessage["role"], content: string, extra: Partial<CreationMessage> = {}): CreationMessage {
|
||
return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra };
|
||
}
|
||
|
||
type CreationShot = { user?: CreationMessage; result?: CreationMessage };
|
||
|
||
function shotsFromMessages(messages: CreationMessage[]): CreationShot[] {
|
||
const shots: CreationShot[] = [];
|
||
for (const message of messages) {
|
||
if (message.role === "user") {
|
||
shots.push({ user: message });
|
||
} else if (shots.length) {
|
||
shots[shots.length - 1].result = message;
|
||
} else {
|
||
shots.push({ result: message });
|
||
}
|
||
}
|
||
return shots;
|
||
}
|
||
|
||
function completedCreationGenerationTask(input: { taskId: string; task?: GenerationTask; mode: "image" | "video"; prompt: string; result: BackendGenerationResult; conversationId: string; messageId: string; batchIndex?: number; batchCount?: number }): GenerationTask {
|
||
const now = new Date().toISOString();
|
||
const task = input.task ?? { id: input.taskId, type: input.mode, status: "succeeded" as const, prompt: input.prompt, attempts: 1, createdAt: now, updatedAt: now };
|
||
return projectGenerationTaskResult({ ...task, status: "succeeded", prompt: input.prompt, clientContext: { conversationId: input.conversationId, messageId: input.messageId, ...(typeof input.batchIndex === "number" ? { batchIndex: input.batchIndex } : {}), ...(typeof input.batchCount === "number" ? { batchCount: input.batchCount } : {}) } }, input.result);
|
||
}
|
||
|
||
export default function CreatePage() {
|
||
const { message: toast, modal } = App.useApp();
|
||
const config = useEffectiveConfig();
|
||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||
const assets = useAssetStore((state) => state.assets);
|
||
const addAsset = useAssetStore((state) => state.addAsset);
|
||
const [conversations, setConversations] = useState<CreationConversation[]>([]);
|
||
const conversationsRef = useRef<CreationConversation[]>([]);
|
||
const [activeId, setActiveId] = useState("");
|
||
const activeIdRef = useRef("");
|
||
const [hydrated, setHydrated] = useState(false);
|
||
const [mode, setMode] = useState<CreationMode>("video");
|
||
const [prompt, setPrompt] = useState("");
|
||
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
|
||
const [draftReferences, setDraftReferences] = useState<CreationReference[]>([]);
|
||
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
|
||
const [ratio, setRatio] = useState("16:9");
|
||
const [seconds, setSeconds] = useState("6");
|
||
const [quality, setQuality] = useState("auto");
|
||
const [videoQuality, setVideoQuality] = useState(config.vquality || "720");
|
||
const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1))));
|
||
const [busy, setBusy] = useState(false);
|
||
const [viewMode, setViewMode] = useState<CreationViewMode>("chat");
|
||
const [selectedShotIndex, setSelectedShotIndex] = useState(-1);
|
||
const [composingNextShot, setComposingNextShot] = useState(false);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const composerFocusRef = useRef<HTMLTextAreaElement>(null);
|
||
const threadScrollRef = useRef<HTMLElement>(null);
|
||
const followLatestMessageRef = useRef(true);
|
||
const taskSyncWarningRef = useRef(false);
|
||
const retryPreparingRef = useRef(new Set<string>());
|
||
const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null);
|
||
const [retrySequence, setRetrySequence] = useState(0);
|
||
|
||
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
|
||
const historyConversations = useMemo(
|
||
() => conversations.filter((conversation) => conversation.id === activeId || conversation.messages.length > 0).sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)),
|
||
[activeId, conversations],
|
||
);
|
||
const preferredModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel;
|
||
const hasPrompt = Boolean(prompt.trim());
|
||
const modelRequirements = useMemo<ModelRequirements>(() => ({
|
||
capability: mode,
|
||
input: {
|
||
textCount: hasPrompt ? 1 : 0,
|
||
imageCount: attachments.filter(isImageAttachment).length,
|
||
videoCount: attachments.filter(isVideoAttachment).length,
|
||
audioCount: 0,
|
||
characterCount: 0,
|
||
},
|
||
videoSeconds: seconds,
|
||
}), [attachments, hasPrompt, mode, seconds]);
|
||
const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel;
|
||
const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]);
|
||
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
|
||
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
|
||
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
|
||
const isEmpty = !activeConversation?.messages.length;
|
||
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
|
||
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
|
||
const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]);
|
||
const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1;
|
||
|
||
useEffect(() => {
|
||
if (mode !== "image") return;
|
||
const normalized = normalizeImageValue(imageProfile, { size: ratio, quality, count });
|
||
setRatio(normalized.size);
|
||
setQuality(normalized.quality);
|
||
setCount(normalized.count);
|
||
}, [mode, selectedModel, imageProfile]);
|
||
|
||
useEffect(() => {
|
||
if (mode !== "video") return;
|
||
const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` });
|
||
setSeconds(normalized.seconds);
|
||
setRatio(normalized.ratio);
|
||
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
|
||
}, [mode, selectedModel, videoProfile]);
|
||
|
||
useEffect(() => {
|
||
const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences);
|
||
if (reconciled.attachments === attachments) return;
|
||
setAttachments(reconciled.attachments);
|
||
if (reconciled.removedReferences.length) setPrompt((current) => removeCreationReferenceTokens(current, reconciled.removedReferences));
|
||
}, [attachments, maxReferences, mentionReferences]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void loadCreationConversations<CreationConversation>().then((stored) => {
|
||
if (cancelled) return;
|
||
const next = stored?.length ? stored : [newConversation()];
|
||
conversationsRef.current = next;
|
||
setConversations(next);
|
||
setActiveId(next[0].id);
|
||
setHydrated(true);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
// 页面卸载只停止当前页面的状态更新,后台任务由任务中心继续执行,返回页面后再恢复状态。
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => () => abortRef.current?.abort(), []);
|
||
|
||
useEffect(() => {
|
||
activeIdRef.current = activeId;
|
||
}, [activeId]);
|
||
|
||
useEffect(() => {
|
||
conversationsRef.current = conversations;
|
||
if (hydrated) void saveCreationConversations(conversations);
|
||
}, [conversations, hydrated]);
|
||
|
||
useEffect(() => {
|
||
if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return;
|
||
let cancelled = false;
|
||
const observationController = new AbortController();
|
||
const applyTasks = async (tasks: GenerationTask[]) => {
|
||
const contextual = attachCreationTaskContexts(tasks, conversations);
|
||
const persistedTasks = await materializeCreationTaskResults(contextual, observationController.signal);
|
||
if (cancelled) return;
|
||
taskSyncWarningRef.current = false;
|
||
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length));
|
||
for (const task of attachable) {
|
||
await consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
|
||
if (cancelled) return;
|
||
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
|
||
applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
|
||
);
|
||
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: generationTaskMaterializedUrls });
|
||
}
|
||
if (!attachable.length && !cancelled) setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks));
|
||
};
|
||
const warnSync = (error: unknown) => {
|
||
if (cancelled || observationController.signal.aborted) return;
|
||
console.warn("创作任务状态同步失败", error);
|
||
if (!taskSyncWarningRef.current) {
|
||
taskSyncWarningRef.current = true;
|
||
toast.warning("任务状态暂时无法同步,请稍后刷新");
|
||
}
|
||
};
|
||
let applyChain = Promise.resolve();
|
||
const unsubscribe = subscribeGenerationTasks(pendingTaskIds, (task) => {
|
||
applyChain = applyChain.then(() => applyTasks([task])).catch(warnSync);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
observationController.abort();
|
||
unsubscribe();
|
||
};
|
||
}, [hydrated, pendingMediaKey, toast]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
listAddedSkills().then(({ skills }) => {
|
||
if (!cancelled) setAddedSkills(skills);
|
||
}).catch(() => {
|
||
if (!cancelled) setAddedSkills([]);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!followLatestMessageRef.current) return;
|
||
const frame = window.requestAnimationFrame(() => {
|
||
const container = threadScrollRef.current;
|
||
if (container) container.scrollTop = container.scrollHeight;
|
||
});
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [activeConversation?.id, activeConversation?.messages]);
|
||
|
||
const updateActive = useCallback((updater: (conversation: CreationConversation) => CreationConversation) => {
|
||
const next = updateCreationConversationSnapshot(conversationsRef.current, activeId, updater);
|
||
conversationsRef.current = next;
|
||
setConversations(next);
|
||
}, [activeId]);
|
||
|
||
const updateConversationMessage = useCallback(async (conversationId: string, id: string, updater: (item: CreationMessage) => CreationMessage) => {
|
||
const next = updateCreationConversationSnapshot(conversationsRef.current, conversationId, (conversation) => ({
|
||
...conversation,
|
||
updatedAt: new Date().toISOString(),
|
||
messages: conversation.messages.map((item) => item.id === id ? updater(item) : item),
|
||
}));
|
||
conversationsRef.current = next;
|
||
setConversations(next);
|
||
await saveCreationConversations(next);
|
||
}, []);
|
||
|
||
const selectMode = (next: CreationMode) => {
|
||
setMode(next);
|
||
const nextModels = selectableModelsByCapability(config, next);
|
||
const current = next === "text" ? config.textModel : next === "image" ? config.imageModel : config.videoModel;
|
||
if (!nextModels.includes(current) && nextModels[0]) {
|
||
updateConfig(next === "text" ? "textModel" : next === "image" ? "imageModel" : "videoModel", nextModels[0]);
|
||
}
|
||
};
|
||
|
||
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => assets
|
||
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" | "audio" }> => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
|
||
.map((asset) => ({
|
||
id: asset.id,
|
||
title: asset.title,
|
||
category: asset.category || "other",
|
||
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
|
||
asset,
|
||
searchText: asset.tags.join(" "),
|
||
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
|
||
})), [assets, mode]);
|
||
const uploadCreationAsset = async (file: File) => {
|
||
if (file.type.startsWith("video/")) {
|
||
const uploaded = await uploadMediaFile(file, "create-upload");
|
||
return {
|
||
asset: creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
|
||
attachment: creationAttachmentFromVideo(file, uploaded),
|
||
};
|
||
}
|
||
if (file.type.startsWith("audio/")) {
|
||
const uploaded = await uploadMediaFile(file, "create-upload");
|
||
return {
|
||
asset: creationAudioAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
|
||
attachment: creationAttachmentFromAudio(file, uploaded),
|
||
};
|
||
}
|
||
if (!file.type.startsWith("image/")) {
|
||
const uploaded = await uploadMediaFile(file, "create-upload");
|
||
return { attachment: creationAttachmentFromDocument(file, uploaded) };
|
||
}
|
||
const uploaded = await uploadImage(file);
|
||
return {
|
||
asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
|
||
attachment: creationAttachmentFromImage(file, uploaded),
|
||
};
|
||
};
|
||
const addAttachments = (files: FileList | File[]) => {
|
||
if ((mode === "image" || mode === "video") && maxReferences === 0) {
|
||
toast.warning(mode === "image" ? "当前图片模型不支持参考图" : "当前模型不支持图生视频");
|
||
return;
|
||
}
|
||
const next = Array.from(files)
|
||
.filter((file) => creationFileAccepted(mode, file))
|
||
.slice(0, Math.max(0, maxReferences - attachments.length));
|
||
if (!next.length) return;
|
||
void Promise.allSettled(next.map(async (file) => {
|
||
const { asset, attachment } = await uploadCreationAsset(file);
|
||
if (asset) addAsset(asset);
|
||
return attachment;
|
||
})).then((settled) => {
|
||
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||
const failed = settled.filter((entry) => entry.status === "rejected");
|
||
if (items.length) setAttachments((current) => [...current, ...items].slice(0, maxReferences));
|
||
if (failed.length) toast.error(`${failed.length} 个参考素材上传失败,请重试`);
|
||
});
|
||
};
|
||
|
||
const uploadLibraryAssets = async (files: FileList | File[]) => {
|
||
const next = Array.from(files).filter((file) => creationFileAccepted(mode, file));
|
||
if (!next.length) return [];
|
||
const settled = await Promise.allSettled(next.map(async (file) => {
|
||
const { asset } = await uploadCreationAsset(file);
|
||
return asset ? addAsset(asset) : "";
|
||
}));
|
||
const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" && entry.value ? [entry.value] : []);
|
||
const failed = settled.filter((entry) => entry.status === "rejected");
|
||
if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`);
|
||
if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`);
|
||
return assetIds;
|
||
};
|
||
|
||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||
if (event.target.files) addAttachments(event.target.files);
|
||
event.target.value = "";
|
||
};
|
||
|
||
const handleLibrarySelect = (selected: Asset[]) => {
|
||
const next = selected.flatMap((asset): CreationAttachment[] => {
|
||
if (asset.kind === "image") return [creationAttachmentFromAsset(asset)];
|
||
if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)];
|
||
if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)];
|
||
return [];
|
||
});
|
||
if (!next.length) return;
|
||
setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences));
|
||
setLibraryOpen(false);
|
||
};
|
||
|
||
const removeAttachment = (id: string) => {
|
||
const reference = mentionReferences.find((item) => item.attachmentId === id);
|
||
setAttachments((current) => current.filter((item) => item.id !== id));
|
||
if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference]));
|
||
};
|
||
|
||
const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => {
|
||
const releaseRetryLock = () => {
|
||
if (retryLockKey) retryPreparingRef.current.delete(retryLockKey);
|
||
};
|
||
const text = prompt.trim();
|
||
if (!text || busy || !activeConversation) {
|
||
releaseRetryLock();
|
||
return;
|
||
}
|
||
if (!selectedModel) {
|
||
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
||
releaseRetryLock();
|
||
return;
|
||
}
|
||
if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) {
|
||
toast.error("当前模型不支持所选视频时长,请重新选择");
|
||
releaseRetryLock();
|
||
return;
|
||
}
|
||
if (attachments.length > maxReferences) {
|
||
toast.warning("参考内容正在按当前模型能力调整,请稍后重试");
|
||
releaseRetryLock();
|
||
return;
|
||
}
|
||
const settings = { ratio, seconds, quality, videoQuality, count };
|
||
const references = selectedCreationReferences(text, mentionReferences);
|
||
// 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。
|
||
const { referenceImages, referenceVideos, referenceAudios } = splitCreationAttachments(attachments);
|
||
const expandedPrompt = expandCreationPrompt(text, references, attachments);
|
||
const referenceMetadata = creationReferenceMetadata(references);
|
||
followLatestMessageRef.current = true;
|
||
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
|
||
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings, ...retryContext });
|
||
const originConversationId = activeConversation.id;
|
||
const updateOriginAssistant = (updater: (item: CreationMessage) => CreationMessage) => updateConversationMessage(originConversationId, assistantMessage.id, updater);
|
||
const boundTaskIds = new Set<string>();
|
||
const boundTaskIdsByBatchIndex = new Map<number, string>();
|
||
const boundTasks = new Map<string, GenerationTask>();
|
||
const bindTask = (task: GenerationTask) => {
|
||
if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id);
|
||
boundTaskIds.add(task.id);
|
||
boundTasks.set(task.id, task);
|
||
updateOriginAssistant((item) => ({ ...item, generationStage: task.stage, generationOperation: task.operation, generationErrorCode: task.errorCode, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])), clientOperationId: task.clientOperationId, retryOf: task.retryOf, attemptGroupId: task.attemptGroupId }));
|
||
if (abortRef.current === controller) {
|
||
abortRef.current = null;
|
||
setBusy(false);
|
||
}
|
||
};
|
||
updateActive((conversation) => ({
|
||
...conversation,
|
||
title: conversation.messages.length ? conversation.title : text.slice(0, 24),
|
||
updatedAt: new Date().toISOString(),
|
||
messages: [...conversation.messages, userMessage, assistantMessage],
|
||
}));
|
||
setPrompt("");
|
||
setAttachments([]);
|
||
setDraftReferences([]);
|
||
setSelectedShotIndex(-1);
|
||
setComposingNextShot(false);
|
||
setBusy(true);
|
||
const controller = new AbortController();
|
||
const requestLifecycle = beginGenerationConsumer(controller.signal);
|
||
abortRef.current = controller;
|
||
const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count };
|
||
try {
|
||
if (mode === "text") {
|
||
const history = await Promise.all([...(activeConversation.messages || []), userMessage].map(async (item) => ({
|
||
role: item.role,
|
||
content: item.role === "user"
|
||
? await buildTextMessageContent(item)
|
||
: item.content,
|
||
})));
|
||
await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), { signal: requestLifecycle.signal });
|
||
} else if (mode === "image") {
|
||
const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1)));
|
||
const settled = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTaskBatch({
|
||
mode: "image",
|
||
prompt: expandedPrompt,
|
||
config: { ...requestConfig, count: "1" },
|
||
referenceImages,
|
||
signal: requestLifecycle.signal,
|
||
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
|
||
onTaskUpdate: bindTask,
|
||
count: taskCount,
|
||
...retryContext,
|
||
}));
|
||
if (requestLifecycle.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||
const boundTaskIdList = Array.from(boundTaskIds);
|
||
const generatedImages = settled.flatMap((entry, batchIndex) => {
|
||
if (entry.status !== "fulfilled") return [];
|
||
return (entry.value.images || []).map((image, resultIndex) => ({
|
||
image,
|
||
taskId: boundTaskIdsByBatchIndex.get(batchIndex) || boundTaskIdList[batchIndex],
|
||
batchIndex,
|
||
resultIndex,
|
||
}));
|
||
});
|
||
const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
|
||
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
|
||
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
|
||
const task = completedCreationGenerationTask({ taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
|
||
const materialized = await consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
|
||
await updateOriginAssistant((item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value);
|
||
}, { signal: requestLifecycle.signal });
|
||
const url = generationTaskMaterializedUrls(materialized)[0];
|
||
if (!url) throw new Error("图片结果资源不可用");
|
||
return url;
|
||
}));
|
||
const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||
const resourceFailures = storedImages.filter((entry) => entry.status === "rejected");
|
||
const failedCount = taskFailures.length + resourceFailures.length;
|
||
if (!resultUrls.length) {
|
||
const reason = taskFailures[0]?.reason || resourceFailures[0]?.reason;
|
||
throw reason instanceof Error ? reason : new Error("后端任务没有返回图片");
|
||
}
|
||
if (failedCount) toast.warning(`${resultUrls.length} 张图片已生成,${failedCount} 张生成失败`);
|
||
updateOriginAssistant((item) => ({ ...item, content: failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成" }));
|
||
} else {
|
||
const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({
|
||
mode: "video",
|
||
prompt: expandedPrompt,
|
||
config: requestConfig,
|
||
referenceImages,
|
||
referenceVideos,
|
||
referenceAudios,
|
||
signal: requestLifecycle.signal,
|
||
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: referenceAudios.length && !referenceImages.length && !referenceVideos.length ? "audio_to_video" : attachments.length ? "image_to_video" : "text_to_video", ...referenceMetadata },
|
||
onTaskUpdate: bindTask,
|
||
...retryContext,
|
||
}));
|
||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||
const taskId = Array.from(boundTaskIds)[0];
|
||
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
|
||
const task = completedCreationGenerationTask({ taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id });
|
||
const materialized = await consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
|
||
await updateOriginAssistant((item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value);
|
||
}, { signal: requestLifecycle.signal });
|
||
if (!generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用");
|
||
}
|
||
updateOriginAssistant((item) => ({ ...item, status: "done" }));
|
||
} catch (error) {
|
||
if (isGenerationTaskCancelled(error, requestLifecycle.signal)) {
|
||
updateOriginAssistant((item) => ({ ...item, status: "cancelled", content: "已停止" }));
|
||
return;
|
||
}
|
||
const message = generationErrorMessage(error);
|
||
updateOriginAssistant((item) => ({ ...item, status: "error", error: message, generationErrorCode: item.generationErrorCode || generationErrorCode(error), generationOperation: item.generationOperation || (mode === "video" ? (attachments.length ? "image_to_video" : "text_to_video") : mode), createdAt: assistantMessage.createdAt, content: "生成失败" }));
|
||
} finally {
|
||
requestLifecycle.release();
|
||
releaseRetryLock();
|
||
if (abortRef.current === controller) {
|
||
abortRef.current = null;
|
||
setBusy(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!retrySequence) return;
|
||
const pending = pendingRetryRef.current;
|
||
if (!pending) return;
|
||
pendingRetryRef.current = null;
|
||
void submit(pending.context, pending.lockKey);
|
||
}, [retrySequence]);
|
||
|
||
const startNewConversation = () => {
|
||
const next = newConversation();
|
||
followLatestMessageRef.current = true;
|
||
setConversations((current) => [next, ...current]);
|
||
setActiveId(next.id);
|
||
setPrompt("");
|
||
setAttachments([]);
|
||
setDraftReferences([]);
|
||
setSelectedShotIndex(-1);
|
||
setComposingNextShot(false);
|
||
setHistoryOpen(false);
|
||
};
|
||
|
||
const selectConversation = (conversation: CreationConversation) => {
|
||
followLatestMessageRef.current = true;
|
||
setActiveId(conversation.id);
|
||
setPrompt("");
|
||
setAttachments([]);
|
||
setDraftReferences([]);
|
||
setSelectedShotIndex(-1);
|
||
setComposingNextShot(false);
|
||
setHistoryOpen(false);
|
||
};
|
||
|
||
const confirmDeleteConversation = (conversation: CreationConversation) => {
|
||
const title = conversation.title.trim() || "新创作";
|
||
const label = title.length > 32 ? `${title.slice(0, 32)}...` : title;
|
||
modal.confirm({
|
||
className: "workspace-modal workspace-modal-compact",
|
||
title: "删除历史对话?",
|
||
content: `确定删除「${label}」吗?这只会删除历史对话记录,不会删除已上传或生成的任何素材。此操作不可撤销。`,
|
||
okText: "删除对话",
|
||
okButtonProps: { danger: true },
|
||
cancelText: "保留",
|
||
onOk: async () => {
|
||
try {
|
||
const remaining = removeCreationConversationSnapshot(conversationsRef.current, conversation.id);
|
||
const sortedRemaining = [...remaining].sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt));
|
||
const fallback = sortedRemaining.find((item) => item.messages.length > 0) || sortedRemaining[0] || newConversation();
|
||
const next = remaining.length ? remaining : [fallback];
|
||
await saveCreationConversations(next);
|
||
conversationsRef.current = next;
|
||
setConversations(next);
|
||
if (activeIdRef.current === conversation.id) {
|
||
followLatestMessageRef.current = true;
|
||
activeIdRef.current = fallback.id;
|
||
setActiveId(fallback.id);
|
||
setPrompt("");
|
||
setAttachments([]);
|
||
setDraftReferences([]);
|
||
setSelectedShotIndex(-1);
|
||
setComposingNextShot(false);
|
||
}
|
||
toast.success("历史对话已删除,素材仍保留");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "历史对话删除失败");
|
||
throw error;
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const restoreMessageDraft = (item: CreationMessage) => {
|
||
const nextMode = item.mode || "text";
|
||
const nextSettings = item.settings;
|
||
setMode(nextMode);
|
||
setPrompt(item.content);
|
||
setAttachments(item.attachments ? [...item.attachments] : []);
|
||
setDraftReferences(item.references ? [...item.references] : []);
|
||
if (item.model) updateConfig(nextMode === "text" ? "textModel" : nextMode === "image" ? "imageModel" : "videoModel", item.model);
|
||
if (!nextSettings) return;
|
||
setRatio(nextSettings.ratio);
|
||
setSeconds(nextSettings.seconds);
|
||
setQuality(nextSettings.quality);
|
||
setVideoQuality(nextSettings.videoQuality);
|
||
setCount(nextSettings.count);
|
||
};
|
||
|
||
const retryFailedMessage = async (item: CreationMessage, index: number) => {
|
||
const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item;
|
||
if (!previous?.content || busy) return;
|
||
const retryOf = item.taskIds?.[0];
|
||
const restoreForRetry = () => {
|
||
followLatestMessageRef.current = true;
|
||
restoreMessageDraft(previous);
|
||
setSelectedShotIndex(-1);
|
||
setComposingNextShot(false);
|
||
const removedIds = new Set([item.id, previous.id]);
|
||
updateActive((conversation) => {
|
||
const messages = conversation.messages.filter((message) => !removedIds.has(message.id));
|
||
const firstPrompt = messages.find((message) => message.role === "user")?.content.trim();
|
||
return { ...conversation, title: firstPrompt ? firstPrompt.slice(0, 24) : "新创作", updatedAt: new Date().toISOString(), messages };
|
||
});
|
||
};
|
||
if (!retryOf) {
|
||
restoreForRetry();
|
||
return;
|
||
}
|
||
if (retryPreparingRef.current.has(retryOf)) return;
|
||
retryPreparingRef.current.add(retryOf);
|
||
try {
|
||
const attemptGroupId = item.attemptGroupId || item.retryOf || retryOf;
|
||
const context: CreationRetryContext = { ...(await createGenerationRetryContext(retryOf, attemptGroupId)), ...(item.taskIds && item.taskIds.length > 1 ? { retryContextsByBatchIndex: await createGenerationBatchRetryContexts(item.taskIds, attemptGroupId) } : {}) };
|
||
restoreForRetry();
|
||
pendingRetryRef.current = { context, lockKey: retryOf };
|
||
setRetrySequence((current) => current + 1);
|
||
} catch (error) {
|
||
retryPreparingRef.current.delete(retryOf);
|
||
toast.error(generationErrorMessage(error));
|
||
}
|
||
};
|
||
|
||
const createVariant = (item: CreationMessage, index: number) => {
|
||
const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item;
|
||
if (!previous?.content || busy) return;
|
||
restoreMessageDraft(previous);
|
||
};
|
||
|
||
const cancelPendingMessage = async (item: CreationMessage) => {
|
||
if (!item.taskIds?.length || !activeConversation) return;
|
||
const settled = await Promise.allSettled(item.taskIds.map((taskId) => cancelGenerationTask(taskId)));
|
||
const fulfilled = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||
const background = fulfilled.map((task) => ({ task, outcome: localDreaminaDetachOutcome(task) })).find((entry) => entry.outcome?.kind === "background");
|
||
if (background?.outcome?.kind === "background") {
|
||
await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: background.outcome!.creationStatus, generationStage: background.task.stage, content: background.outcome!.message }));
|
||
toast.info(background.outcome.message);
|
||
return;
|
||
}
|
||
const cancelled = fulfilled.filter((task) => task.status === "cancelled");
|
||
if (cancelled.length === item.taskIds.length) {
|
||
const outcome = localDreaminaDetachOutcome(cancelled[0]!);
|
||
await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: outcome?.creationStatus ?? "cancelled", generationStage: "cancelled", content: outcome?.message ?? cancelled[0]!.error ?? "任务已取消" }));
|
||
}
|
||
};
|
||
|
||
if (!hydrated || !activeConversation) return <div className="grid h-full place-items-center"><Spin /></div>;
|
||
|
||
const handleThreadScroll = () => {
|
||
const container = threadScrollRef.current;
|
||
if (!container) return;
|
||
followLatestMessageRef.current = container.scrollHeight - container.scrollTop - container.clientHeight <= 160;
|
||
};
|
||
|
||
const nextShotNumber = shots.length + 1;
|
||
|
||
const beginComposeNextShot = () => {
|
||
setComposingNextShot(true);
|
||
setSelectedShotIndex(-1);
|
||
window.requestAnimationFrame(() => composerFocusRef.current?.focus());
|
||
};
|
||
|
||
const cancelComposeNextShot = () => setComposingNextShot(false);
|
||
|
||
const composerProps = {
|
||
mode,
|
||
prompt,
|
||
setPrompt,
|
||
busy,
|
||
attachments,
|
||
maxReferences,
|
||
references: mentionReferences,
|
||
onRemoveAttachment: removeAttachment,
|
||
onOpenLibrary: () => setLibraryOpen(true),
|
||
fileInputRef,
|
||
onFileChange: handleFileChange,
|
||
onModeChange: selectMode,
|
||
model: selectedModel,
|
||
modelRequirements,
|
||
imageProfile,
|
||
videoProfile,
|
||
config,
|
||
onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value),
|
||
ratio,
|
||
setRatio,
|
||
seconds,
|
||
setSeconds,
|
||
quality,
|
||
setQuality,
|
||
videoQuality,
|
||
setVideoQuality,
|
||
count,
|
||
setCount,
|
||
composerFocusRef,
|
||
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
|
||
onSubmit: () => void submit(),
|
||
onStop: () => abortRef.current?.abort(),
|
||
};
|
||
|
||
const visibleShot = shots[visibleShotIndex];
|
||
const visibleShotResultIndex = visibleShot?.result ? activeConversation.messages.indexOf(visibleShot.result) : -1;
|
||
|
||
return <>
|
||
<div className="creation-home relative flex h-full min-h-0 flex-col overflow-hidden">
|
||
{isEmpty ? <>
|
||
<div className="creation-top-actions">
|
||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
|
||
</div>
|
||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
|
||
<CreationEmptyBanner />
|
||
<div className="creation-chat-intro">
|
||
<span className="creation-intro-signal" aria-hidden="true" />
|
||
<p>影策 · AI 影视创作工作台</p>
|
||
<h1>把脑海里的画面,<span className="creation-intro-emphasis"><span className="is-pink">交给影策</span><span className="is-blue">拍出来</span></span></h1>
|
||
</div>
|
||
<div className="creation-empty-composer">
|
||
<CreationComposer {...composerProps} variant="empty" />
|
||
</div>
|
||
<CreationEmptySuggest
|
||
onStartPrompt={(nextMode, prompt) => { selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||
onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }}
|
||
/>
|
||
</main>
|
||
</> : viewMode === "chat" ? <div className="creation-thread-workbench">
|
||
<CreationWorkspaceToolbar viewMode={viewMode} onViewModeChange={setViewMode} onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} />
|
||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">
|
||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <CreationMessageView
|
||
key={item.id}
|
||
item={item}
|
||
modelName={item.model ? modelDisplayName(config, item.model) : ""}
|
||
onRetryFailure={() => retryFailedMessage(item, index)}
|
||
onCreateVariant={() => createVariant(item, index)}
|
||
onCancel={() => void cancelPendingMessage(item)}
|
||
/>)}</div></section>
|
||
</main>
|
||
<section className="creation-thread-composer"><CreationComposer {...composerProps} variant="thread" /></section>
|
||
</div> : <div className="storyboard-workbench">
|
||
<StoryboardToolbar
|
||
shots={shots}
|
||
activeIndex={visibleShotIndex}
|
||
composing={composingNextShot}
|
||
onSelect={(index) => { setSelectedShotIndex(index); setComposingNextShot(false); }}
|
||
onBeginCompose={beginComposeNextShot}
|
||
onCancelCompose={cancelComposeNextShot}
|
||
onNewConversation={startNewConversation}
|
||
onOpenHistory={() => setHistoryOpen(true)}
|
||
viewMode={viewMode}
|
||
onViewModeChange={setViewMode}
|
||
/>
|
||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="storyboard-workbench-stage creation-scrollbar">
|
||
<div className="storyboard-workbench-stage-inner">
|
||
{composingNextShot ? <StoryboardNextShotCard shotNumber={nextShotNumber} onCancel={cancelComposeNextShot} /> : visibleShot ? <StoryboardShotCard
|
||
shot={visibleShot}
|
||
shotNumber={visibleShotIndex + 1}
|
||
modelName={visibleShot.result?.model ? modelDisplayName(config, visibleShot.result.model) : ""}
|
||
busy={busy}
|
||
onRetryFailure={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) retryFailedMessage(visibleShot.result, visibleShotResultIndex); }}
|
||
onCreateVariant={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) createVariant(visibleShot.result, visibleShotResultIndex); }}
|
||
onCancel={() => { if (visibleShot.result) void cancelPendingMessage(visibleShot.result); }}
|
||
/> : null}
|
||
</div>
|
||
</main>
|
||
<section className="storyboard-workbench-composer">
|
||
<CreationComposer {...composerProps} variant="thread" />
|
||
</section>
|
||
</div>}
|
||
</div>
|
||
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} />
|
||
<AssetLibraryPickerModal
|
||
open={libraryOpen}
|
||
items={libraryItems}
|
||
categoryLabels={creationAssetCategoryLabels}
|
||
initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))}
|
||
upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }}
|
||
onClose={() => setLibraryOpen(false)}
|
||
onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))}
|
||
/>
|
||
</>;
|
||
}
|
||
|
||
const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
|
||
|
||
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) {
|
||
const [keyword, setKeyword] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (open) setKeyword("");
|
||
}, [open]);
|
||
|
||
const visibleConversations = useMemo(() => {
|
||
const query = keyword.trim().toLowerCase();
|
||
if (!query) return conversations;
|
||
return conversations.filter((conversation) => {
|
||
const latest = conversationPreviewMessage(conversation);
|
||
const searchable = [
|
||
conversation.title,
|
||
...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]),
|
||
latest?.mode ? modeLabels[latest.mode] : "创作",
|
||
formatConversationTime(conversation.updatedAt),
|
||
].filter(Boolean).join(" ").toLowerCase();
|
||
return searchable.includes(query);
|
||
});
|
||
}, [conversations, keyword]);
|
||
|
||
return <Drawer open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||
<div className="creation-history-content">
|
||
<label className="creation-history-search">
|
||
<Search aria-hidden="true" />
|
||
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
|
||
</label>
|
||
{visibleConversations.length ? <ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
|
||
{visibleConversations.map((conversation) => {
|
||
const latest = conversationPreviewMessage(conversation);
|
||
const active = conversation.id === activeId;
|
||
return <li key={conversation.id} className={active ? "is-active" : undefined}>
|
||
<button type="button" className="creation-history-item-main" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
|
||
<span className="creation-history-time"><time dateTime={conversation.updatedAt}>{formatConversationTime(conversation.updatedAt)}</time><em>{latest?.mode ? modeLabels[latest.mode] : "创作"}</em></span>
|
||
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
|
||
<span className="creation-history-snippet">{latest ? displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作" : "还没有开始创作"}</span>
|
||
</button>
|
||
<Tooltip title="删除对话"><button type="button" className="creation-history-delete" aria-label={`删除对话:${conversation.title.trim() || "新创作"}`} onClick={() => onDelete(conversation)}><Trash2 /></button></Tooltip>
|
||
</li>;
|
||
})}
|
||
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}
|
||
</div>
|
||
</Drawer>;
|
||
}
|
||
|
||
function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) {
|
||
return <div className="creation-view-switch" role="group" aria-label="创作视图">
|
||
<button type="button" aria-pressed={viewMode === "chat"} onClick={() => onChange("chat")}><MessageSquareText />连续对话</button>
|
||
<button type="button" aria-pressed={viewMode === "storyboard"} onClick={() => onChange("storyboard")}><Clapperboard />镜头创作</button>
|
||
</div>;
|
||
}
|
||
|
||
function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) {
|
||
return <header className="creation-thread-toolbar">
|
||
<CreationViewSwitch viewMode={viewMode} onChange={onViewModeChange} />
|
||
<div className="storyboard-workbench-bar-actions">
|
||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="storyboard-workbench-bar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="storyboard-workbench-bar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||
</div>
|
||
</header>;
|
||
}
|
||
|
||
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||
if (item.role === "user") return <CreationUserMessage item={item} />;
|
||
const mode = item.mode || "text";
|
||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||
return <article className={`creation-assistant-message is-${mode}`}>
|
||
<div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>
|
||
{mode === "text" ? <div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} />}
|
||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||
</article>;
|
||
}
|
||
|
||
function CreationUserMessage({ item }: { item: CreationMessage }) {
|
||
const [previewUrl, setPreviewUrl] = useState("");
|
||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||
const copyText = useCopyText();
|
||
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
|
||
return <article className="creation-user-message">
|
||
<div className="creation-user-message-meta"><span>你</span>{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}<Tooltip title="复制消息"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip></div>
|
||
<div className="creation-user-message-copy-wrap"><p>{visiblePrompt}</p></div>
|
||
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
|
||
{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
|
||
const kind = creationAttachmentKind(attachment);
|
||
const previewable = kind === "image" || kind === "video";
|
||
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
||
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : url); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !url}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <img src={url} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
||
})}</div> : null}
|
||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||
</article>;
|
||
}
|
||
|
||
function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||
const [previewUrl, setPreviewUrl] = useState("");
|
||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||
const assets = useAssetStore((state) => state.assets);
|
||
const resultUrls = item.resultUrls || [];
|
||
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
|
||
const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas";
|
||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} onCancel={item.taskIds?.length ? onCancel : undefined} />;
|
||
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||
const isVideo = item.mode === "video";
|
||
return <div className="creation-media-result">
|
||
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成同款</button><Link to={canvasPath}>{resultAssetIds.length ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||
</div>;
|
||
}
|
||
|
||
function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) {
|
||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><Sparkles /></span><span className="sr-only">影策正在生成{mode === "video" ? "视频" : "图像"}</span>{onCancel ? <button type="button" onClick={onCancel}>取消任务</button> : null}</div>;
|
||
}
|
||
|
||
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
|
||
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
||
return <span key={reference.id} className="creation-user-message-reference">{reference.previewUrl && (reference.kind === "image" || reference.kind === "video") ? <img src={reference.previewUrl} alt="" /> : <Icon />}<span>{reference.label}</span></span>;
|
||
})}</div>;
|
||
}
|
||
|
||
function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
|
||
return <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width={type === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{url ? type === "video" ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : <img className="creation-media-preview-image" src={url} alt="媒体预览" /> : null}</Modal>;
|
||
}
|
||
|
||
function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false, onPreview, onRemove, onAdd }: {
|
||
item: CreationAttachment;
|
||
primary?: boolean;
|
||
canAddMore?: boolean;
|
||
onPreview: (type: "image" | "video", url: string) => void;
|
||
onRemove: (id: string) => void;
|
||
onAdd?: () => void;
|
||
}) {
|
||
const kind = creationAttachmentKind(item);
|
||
const previewable = kind === "image" || kind === "video";
|
||
const url = (kind === "video" ? item.url : item.previewUrl) || "";
|
||
return <div className={primary ? "creation-chat-reference is-paper creation-chat-reference-media" : "creation-chat-attachment"}>
|
||
<button type="button" className={`creation-chat-attachment-preview${previewable ? "" : " is-file"}`} onClick={() => { if (previewable) onPreview(kind === "video" ? "video" : "image", url); }} aria-label={previewable ? `放大预览 ${item.name}` : item.name} disabled={previewable && !url}>
|
||
{kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <img src={item.previewUrl} alt={item.name} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>}
|
||
{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}
|
||
</button>
|
||
<button type="button" className="creation-chat-attachment-remove" onClick={() => onRemove(item.id)} aria-label={`移除 ${item.name}`}><X /></button>
|
||
{primary && canAddMore && onAdd ? <Tooltip title="添加更多参考内容"><button type="button" className="creation-chat-reference-add" onClick={onAdd} aria-label="添加更多参考内容"><Plus /></button></Tooltip> : null}
|
||
</div>;
|
||
}
|
||
|
||
type ComposerProps = {
|
||
variant: "empty" | "thread";
|
||
mode: CreationMode;
|
||
prompt: string;
|
||
setPrompt: (value: string) => void;
|
||
busy: boolean;
|
||
attachments: CreationAttachment[];
|
||
maxReferences: number;
|
||
references: CreationReference[];
|
||
onRemoveAttachment: (id: string) => void;
|
||
onOpenLibrary: () => void;
|
||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||
onModeChange: (mode: CreationMode) => void;
|
||
model: string;
|
||
modelRequirements: ModelRequirements;
|
||
videoProfile: VideoCapabilityConfig;
|
||
imageProfile: ImageCapabilityConfig;
|
||
config: ReturnType<typeof useEffectiveConfig>;
|
||
onModelChange: (value: string) => void;
|
||
ratio: string;
|
||
setRatio: (value: string) => void;
|
||
seconds: string;
|
||
setSeconds: (value: string) => void;
|
||
quality: string;
|
||
setQuality: (value: string) => void;
|
||
videoQuality: string;
|
||
setVideoQuality: (value: string) => void;
|
||
count: string;
|
||
setCount: (value: string) => void;
|
||
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
|
||
placeholderOverride?: string;
|
||
onSubmit: () => void;
|
||
onStop: () => void;
|
||
};
|
||
|
||
function CreationComposer(props: ComposerProps) {
|
||
const [previewUrl, setPreviewUrl] = useState("");
|
||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||
const canSubmit = Boolean(props.prompt.trim()) && !props.busy;
|
||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||
const credits = requestCreditCost({
|
||
channelMode: priceChannel.scope === "system" ? "remote" : "local",
|
||
modelCosts: priceChannel.modelCosts,
|
||
model: modelOptionName(props.model),
|
||
count: props.mode === "image" ? props.count : 1,
|
||
seconds: props.mode === "video" ? props.seconds : 1,
|
||
});
|
||
const showCost = creditsEnabled && credits !== null;
|
||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||
const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||
const placeholder = props.mode === "text"
|
||
? "描述你的故事、角色或想继续讨论的创意"
|
||
: props.mode === "image"
|
||
? "描述画面、人物、场景、构图与风格"
|
||
: "描述镜头内容、运动、光线与节奏";
|
||
const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作";
|
||
const imageReferencesSupported = props.imageProfile.references.maxImages > 0;
|
||
const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video");
|
||
const [primaryAttachment, ...secondaryAttachments] = props.attachments;
|
||
const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences;
|
||
const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1;
|
||
const previewAttachment = (type: "image" | "video", url: string) => {
|
||
setPreviewType(type);
|
||
setPreviewUrl(url);
|
||
};
|
||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||
<div className="creation-chat-writing-surface">
|
||
<input ref={props.fileInputRef} type="file" hidden accept={creationUploadAccept(props.mode)} multiple onChange={props.onFileChange} />
|
||
{primaryAttachment ? <CreationAttachmentThumbnail item={primaryAttachment} primary canAddMore={canAddMoreReferences && !props.busy} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} onAdd={props.onOpenLibrary} /> : <Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-reference is-paper" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><Plus /><span>参考内容</span></button></Tooltip>}
|
||
<div className="creation-chat-editor">
|
||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||
{secondaryAttachments.length ? <div className="creation-chat-attachment-strip">{secondaryAttachments.map((item) => <CreationAttachmentThumbnail key={item.id} item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />)}</div> : null}
|
||
</div>
|
||
</div>
|
||
<footer className="creation-chat-dock">
|
||
<div className="creation-chat-controls">
|
||
<VoiceRecordingButton
|
||
disabled={props.busy}
|
||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||
/>
|
||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
||
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
|
||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
|
||
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
|
||
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||
</div>
|
||
<Button
|
||
type="text"
|
||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||
danger={props.busy}
|
||
disabled={!props.busy && !canSubmit}
|
||
style={{
|
||
color: !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
|
||
"--canvas-composer-submit-action": !props.busy && !canSubmit ? "var(--creation-surface-hover)" : props.busy ? "var(--status-error)" : "var(--creation-text)",
|
||
"--canvas-composer-submit-action-fg": !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
|
||
} as CSSProperties}
|
||
onClick={props.busy ? props.onStop : props.onSubmit}
|
||
aria-label={actionLabel}
|
||
title={actionLabel}
|
||
>
|
||
{showCost ? <span className="canvas-node-composer-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
|
||
<span className="canvas-node-composer-submit-action" aria-hidden>{props.busy ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}</span>
|
||
</Button>
|
||
</footer>
|
||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||
</section>;
|
||
}
|
||
|
||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||
const [open, setOpen] = useState(false);
|
||
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
|
||
{ mode: "video", icon: <Film />, label: "视频生成" },
|
||
{ mode: "image", icon: <ImageIcon />, label: "图片生成" },
|
||
{ mode: "text", icon: <MessageSquareText />, label: "文本创作" },
|
||
];
|
||
const current = items.find((item) => item.mode === mode) || items[0];
|
||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-mode-picker-menu" role="listbox" aria-label="选择生成类型">{items.map((item) => <button key={item.mode} type="button" role="option" aria-selected={item.mode === mode} className={item.mode === mode ? "is-selected" : ""} onClick={() => { onModeChange(item.mode); setOpen(false); }}><span className="creation-menu-icon">{item.icon}</span><span>{item.label}</span>{item.mode === mode ? <Check /> : null}</button>)}</div>}>
|
||
<button type="button" className="creation-chat-control is-mode" aria-label={`生成类型:${current.label}`}>{current.icon}<span>{current.label}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||
</Popover>;
|
||
}
|
||
|
||
function GenerationSettingsMenu(props: ComposerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio));
|
||
const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" });
|
||
const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动";
|
||
const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size);
|
||
const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : [];
|
||
const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined;
|
||
const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1");
|
||
const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto";
|
||
const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : [];
|
||
const imageRatios = usesImageResolutionPicker
|
||
? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio)))
|
||
: props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value);
|
||
const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios;
|
||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||
const selectImageRatio = (nextRatio: string) => {
|
||
if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") {
|
||
props.setRatio(nextRatio);
|
||
return;
|
||
}
|
||
props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio);
|
||
};
|
||
const selectImageResolution = (choice: ImageResolutionChoice) => {
|
||
if (choice === "auto") {
|
||
props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio);
|
||
return;
|
||
}
|
||
const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size;
|
||
if (nextSize) props.setRatio(nextSize);
|
||
};
|
||
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
|
||
const imageSummary = [
|
||
...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
|
||
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
|
||
].join(" · ");
|
||
const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
|
||
const panel = <div className="creation-parameter-menu">
|
||
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
|
||
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
|
||
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
|
||
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
|
||
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
|
||
</>}
|
||
</div>;
|
||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={panel}>
|
||
<button type="button" className="creation-chat-control" aria-label={`生成设置:${summary}`}><SlidersHorizontal /><span>{summary}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||
</Popover>;
|
||
}
|
||
|
||
function SettingSection({ title, value, children }: { title: string; value?: string; children: ReactNode }) {
|
||
return <section className="creation-parameter-section"><header><h3>{title}</h3>{value ? <span>{value}</span> : null}</header>{children}</section>;
|
||
}
|
||
|
||
function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) {
|
||
const [open, setOpen] = useState(false);
|
||
const value = Number(normalizeVideoValue(profile, { seconds }).seconds);
|
||
const presets = profile.duration.selection === "enum" ? videoDurationOptions(profile) : [];
|
||
const fallbackPreset = presets.length ? presets : [profile.duration.default];
|
||
const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...fallbackPreset);
|
||
const max = profile.duration.selection === "range" ? Math.max(min, profile.duration.max || min) : Math.max(...fallbackPreset);
|
||
const step = Math.max(1, profile.duration.step || 1);
|
||
const durationControl = profile.duration.selection === "range" ? <>
|
||
<input className="h-8 w-full" style={{ accentColor: "var(--creation-text)" }} type="range" min={min} max={max} step={step} value={value} aria-label="视频时长(秒)" onChange={(event) => onChange(event.target.value)} />
|
||
<div className="flex justify-between px-0.5 text-[var(--fs-tiny)] text-[var(--creation-muted)]"><span>{min}s</span><span>{max}s</span></div>
|
||
<label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min={min} max={max} step={step} inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label>
|
||
</> : <div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div>;
|
||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div>{durationControl}</div>}>
|
||
<button type="button" className="creation-chat-control is-duration" aria-label={`视频时长:${value}秒`}><Clock3 /><span>{value}s</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||
</Popover>;
|
||
}
|
||
|
||
const creationEmptyBannerFrames = [
|
||
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "SC.01 · 雨夜霓虹" },
|
||
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "SC.02 · 暗巷追逐" },
|
||
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "SC.03 · 天台重逢" },
|
||
];
|
||
|
||
function CreationEmptyBanner() {
|
||
return <div className="creation-empty-art" aria-hidden="true">
|
||
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
|
||
<img src={frame.src} alt="" />
|
||
<span>{frame.caption}</span>
|
||
</figure>)}
|
||
<span className="creation-empty-art-caption"><span>影策</span>把每一帧,交给镜头导演</span>
|
||
</div>;
|
||
}
|
||
|
||
const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [
|
||
{ mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" },
|
||
{ mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true },
|
||
{ mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" },
|
||
{ mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" },
|
||
];
|
||
|
||
function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
|
||
return <div className="creation-empty-suggest">
|
||
{creationEmptySuggestions.map((item) => {
|
||
const Icon = item.icon;
|
||
return <button key={item.title} type="button" className="suggest-card" onClick={() => { if (item.openLibrary) onOpenLibrary(); else onStartPrompt(item.mode, item.prompt); }}>
|
||
<span className={`suggest-icon is-${item.mode}`}><Icon size={15} strokeWidth={2} /></span>
|
||
<span className="suggest-copy"><strong>{item.title}</strong><span>{item.hint}</span></span>
|
||
</button>;
|
||
})}
|
||
</div>;
|
||
}
|
||
|
||
type CreationThinking = { title: string; hint: string; steps: string[] };
|
||
|
||
function thinkingFor(mode: CreationMode): CreationThinking {
|
||
if (mode === "image") return { title: "正在为你画这一镜", hint: "影策正在理解你的构图意图,并把画面交给模型出图。", steps: ["理解构图", "定调画风", "生成画面"] };
|
||
if (mode === "text") return { title: "正在为你写这段", hint: "影策正在梳理你的创作脉络,组织语言与结构。", steps: ["梳理脉络", "组织语言", "输出段落"] };
|
||
return { title: "正在为你拍这一镜", hint: "影策正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。", steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] };
|
||
}
|
||
|
||
function directorNoteFor(mode: CreationMode, settings: CreationSettings): string {
|
||
if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`;
|
||
if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`;
|
||
return "";
|
||
}
|
||
|
||
function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) {
|
||
const [railOpen, setRailOpen] = useState(false);
|
||
const nextShotNumber = shots.length + 1;
|
||
const closeRail = () => setRailOpen(false);
|
||
const statusOf = (shot: CreationShot) => shot.result?.status || "queued";
|
||
const shotTitle = (shot: CreationShot) => shot.user ? displayCreationPrompt(shot.user.content, shot.user.references || []).trim() || "未命名镜头" : "镜头";
|
||
return <header className="storyboard-workbench-bar" aria-label="镜头工具条">
|
||
<div className="storyboard-workbench-rail">
|
||
<Tooltip title="镜头时间线"><button type="button" className={`storyboard-workbench-rail-button${railOpen ? " is-open" : ""}${composing ? " is-draft" : ""}`} aria-expanded={railOpen} aria-label="镜头时间线" onClick={() => setRailOpen((value) => !value)}><Film /><span className="storyboard-workbench-rail-badge">{composing ? nextShotNumber : shots.length}</span></button></Tooltip>
|
||
{railOpen ? <div className="storyboard-workbench-rail-pop" role="listbox" aria-label="镜头列表">
|
||
<div className="storyboard-workbench-rail-pop-head"><span className="storyboard-workbench-rail-pop-title"><Clapperboard />镜头时间线<small>{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}</small></span><button type="button" className="storyboard-workbench-rail-pop-close" aria-label="关闭镜头列表" onClick={closeRail}><X /></button></div>
|
||
<ul className="creation-scrollbar">
|
||
{shots.map((shot, index) => {
|
||
const status = statusOf(shot);
|
||
const title = shotTitle(shot);
|
||
const thumbUrl = shot.result?.resultUrls?.[0];
|
||
const thumbIsVideo = shot.result?.mode === "video";
|
||
return <li key={shot.user?.id || shot.result?.id || index}>
|
||
<button type="button" className={`storyboard-workbench-rail-row${index === activeIndex && !composing ? " is-active" : ""}`} onClick={() => { onSelect(index); closeRail(); }}>
|
||
<span className="storyboard-workbench-rail-thumb">{thumbUrl ? (thumbIsVideo ? <video muted preload="metadata" src={thumbUrl} /> : <img src={thumbUrl} alt="" />) : <span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(index + 1).padStart(2, "0")}</em></span>}</span>
|
||
<span className="storyboard-workbench-rail-info">
|
||
<span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(index + 1).padStart(2, "0")}</span><span className={`storyboard-workbench-rail-row-state is-${status}`}>{status === "pending" ? "生成中" : status === "error" ? "失败" : status === "done" ? "完成" : "待生成"}</span>{shot.result?.createdAt ? <time dateTime={shot.result.createdAt}>{formatMessageTime(shot.result.createdAt)}</time> : null}</span>
|
||
<span className="storyboard-workbench-rail-row-title">{title}</span>
|
||
</span>
|
||
</button>
|
||
</li>;
|
||
})}
|
||
{composing ? <li><button type="button" className="storyboard-workbench-rail-row is-draft" onClick={() => { onCancelCompose(); closeRail(); }}><span className="storyboard-workbench-rail-thumb"><span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(nextShotNumber).padStart(2, "0")}</em></span></span><span className="storyboard-workbench-rail-info"><span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(nextShotNumber).padStart(2, "0")}</span><span className="storyboard-workbench-rail-row-state">待撰写</span></span><span className="storyboard-workbench-rail-row-title">等待你的脚本</span></span></button></li> : null}
|
||
</ul>
|
||
<button type="button" className="storyboard-workbench-rail-pop-add" onClick={() => { closeRail(); onBeginCompose(); }}><Plus />新增镜头</button>
|
||
</div> : null}
|
||
</div>
|
||
<div className="storyboard-workbench-bar-actions">
|
||
<CreationViewSwitch viewMode={viewMode} onChange={onViewModeChange} />
|
||
<Tooltip title={composing ? "收起下一镜" : "新增镜头"}><button type="button" aria-label={composing ? "收起下一镜" : "新增镜头"} className="storyboard-workbench-bar-action" onClick={composing ? onCancelCompose : onBeginCompose}>{composing ? <X /> : <Clapperboard />}</button></Tooltip>
|
||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="storyboard-workbench-bar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="storyboard-workbench-bar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||
</div>
|
||
</header>;
|
||
}
|
||
|
||
function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant, onCancel }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||
const user = shot.user;
|
||
const result = shot.result;
|
||
const status = result?.status || "queued";
|
||
const mode = result?.mode || user?.mode || "video";
|
||
const briefVisible = Boolean(user?.content.trim() || user?.references?.length || user?.attachments?.length);
|
||
const copyText = useCopyText();
|
||
const assets = useAssetStore((state) => state.assets);
|
||
const visiblePrompt = user ? displayCreationPrompt(user.content, user.references || []) : "";
|
||
const resultUrls = result?.resultUrls || [];
|
||
const resultAssetIds = result && resultUrls.length ? creationResultAssetIds(assets, { messageId: result.id, taskIds: result.taskIds || [], resultUrls }) : [];
|
||
const canvasHandoffPath = result ? creationCanvasHandoffPath(resultAssetIds, resultUrls.length) : "";
|
||
const canvasPath = canvasHandoffPath || "/canvas";
|
||
return <article className={`storyboard-workbench-card is-${status}`}>
|
||
<header className="storyboard-workbench-card-head">
|
||
<div className="storyboard-workbench-card-heading">
|
||
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>镜头 {shotNumber}</span>
|
||
<span className="storyboard-workbench-card-mode">{mode === "video" ? <Film /> : mode === "image" ? <ImageIcon /> : <MessageSquareText />}{modeLabels[mode]}</span>
|
||
{modelName ? <span className="storyboard-workbench-card-model">{modelName}</span> : null}
|
||
{status === "pending" ? <span className="storyboard-workbench-card-state is-pending"><LoaderCircle className="animate-spin" />生成中</span> : status === "error" ? <span className="storyboard-workbench-card-state is-error">生成失败</span> : status === "done" ? <span className="storyboard-workbench-card-state is-done"><Check />已完成</span> : <span className="storyboard-workbench-card-state">待生成</span>}
|
||
</div>
|
||
<div className="storyboard-workbench-card-actions">
|
||
{status === "error" ? <button type="button" onClick={onRetryFailure} disabled={busy}><RefreshCw />重新生成</button> : null}
|
||
{status === "done" && result?.resultUrls?.length ? <button type="button" onClick={onCreateVariant} disabled={busy}><RefreshCw />生成变体</button> : null}
|
||
{status === "done" && resultUrls.length ? <Link to={canvasPath}>{canvasHandoffPath ? "添加到画布" : "打开画布"}</Link> : null}
|
||
{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}
|
||
</div>
|
||
</header>
|
||
<div className="storyboard-workbench-card-body">
|
||
<div className="storyboard-workbench-thread" aria-label={`镜头 ${shotNumber} 的对话过程`}>
|
||
{briefVisible && user ? <div className="storyboard-workbench-turn is-user">
|
||
<div className="storyboard-workbench-turn-copy">
|
||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role">{shotScriptLabels[mode]}</span>{user.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={user.createdAt}>{formatMessageTime(user.createdAt)}</time> : null}<Tooltip title="复制消息"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip></div>
|
||
<div className="storyboard-workbench-turn-bubble">
|
||
<p className="storyboard-workbench-turn-text">{visiblePrompt}</p>
|
||
{user.references?.length ? <CreationMessageReferences references={user.references} /> : null}
|
||
{user.attachments?.length ? <StoryboardBriefAttachments attachments={user.attachments} /> : null}
|
||
</div>
|
||
</div>
|
||
</div> : null}
|
||
{briefVisible && user ? <div className="storyboard-workbench-handoff" aria-hidden="true"><span className="storyboard-workbench-handoff-rail" /><span className="storyboard-workbench-handoff-badge"><ArrowDown />交给影策 AI</span><span className="storyboard-workbench-handoff-rail" /></div> : null}
|
||
<div className="storyboard-workbench-turn is-ai">
|
||
<span className="storyboard-workbench-ai-avatar"><Clapperboard /></span>
|
||
<div className="storyboard-workbench-turn-copy">
|
||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role is-ai"><Sparkles />影策 AI</span>{modelName ? <span className="storyboard-workbench-turn-model">{modelName}</span> : null}{result?.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={result.createdAt}>{formatMessageTime(result.createdAt)}</time> : null}</div>
|
||
<div className="storyboard-workbench-turn-bubble">
|
||
<StoryboardShotResult result={result} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} canvasPath={canvasPath} canvasHandoffAvailable={Boolean(canvasHandoffPath)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>;
|
||
}
|
||
|
||
function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; onCancel: () => void }) {
|
||
return <article className="storyboard-workbench-card is-next">
|
||
<header className="storyboard-workbench-card-head">
|
||
<div className="storyboard-workbench-card-heading">
|
||
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>下一镜 {shotNumber}</span>
|
||
<span className="storyboard-workbench-card-state is-draft"><Clapperboard />待撰写</span>
|
||
</div>
|
||
<div className="storyboard-workbench-card-actions">
|
||
<button type="button" onClick={onCancel}><X />取消撰写</button>
|
||
</div>
|
||
</header>
|
||
<div className="storyboard-workbench-card-body">
|
||
<div className="storyboard-workbench-next-panel">
|
||
<span className="storyboard-workbench-next-panel-icon"><Clapperboard /></span>
|
||
<div className="storyboard-workbench-next-panel-copy">
|
||
<strong>SC.{String(shotNumber).padStart(2, "0")} 等待你的脚本</strong>
|
||
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 SC.{String(shotNumber).padStart(2, "0")} 自动加入镜头轨道。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>;
|
||
}
|
||
|
||
function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState("");
|
||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||
return <><div className="creation-user-message-attachments storyboard-workbench-brief-attachments">{attachments.map((attachment) => {
|
||
const kind = creationAttachmentKind(attachment);
|
||
const previewable = kind === "image" || kind === "video";
|
||
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
||
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : url); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !url}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <img src={url} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
||
})}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></>;
|
||
}
|
||
|
||
function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, onCancel, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) {
|
||
const [previewUrl, setPreviewUrl] = useState("");
|
||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
|
||
if (!result) return <div className="storyboard-workbench-empty"><Film />这一镜还没开始——在下方写出你的脚本,我来接手。</div>;
|
||
const mode = result.mode || "video";
|
||
const status = result.status || "queued";
|
||
const resultUrls = result.resultUrls || [];
|
||
if (status === "pending" || status === "queued") {
|
||
const thinking = thinkingFor(mode);
|
||
const cancellationCopy = result.taskIds?.map((taskId) => localDreaminaCancellationCopy({ id: taskId, status: "running", stage: result.generationStage, receiptRecorded: result.generationStage === "submitted" || result.generationStage === "generating" })).find(Boolean);
|
||
const hasLocalDreaminaTask = Boolean(result.taskIds?.some(isLocalDreaminaTaskId));
|
||
const showCancellationAction = !hasLocalDreaminaTask || Boolean(cancellationCopy);
|
||
return <div className="storyboard-workbench-pending"><div className="storyboard-workbench-thinking">
|
||
<span className="storyboard-workbench-thinking-copy"><strong>{thinking.title}</strong><span>{thinking.hint}</span></span>
|
||
<span className="storyboard-workbench-pipeline" aria-hidden="true">{thinking.steps.map((step, index) => <em key={step} style={{ "--step": index } as CSSProperties}><i>{String(index + 1).padStart(2, "0")}</i>{step}</em>)}</span>
|
||
{result.taskIds?.length && showCancellationAction ? <button type="button" onClick={onCancel}>{cancellationCopy?.action || "取消任务"}</button> : null}
|
||
</div></div>;
|
||
}
|
||
if (status === "error") return <div className="storyboard-workbench-error"><span>{generationErrorMessage(result.error || "")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||
if (status === "cancelled") return <div className="storyboard-workbench-error"><span>{result.content || "已停止"}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||
if (mode === "text") return <div className="creation-message-content storyboard-workbench-text">{result.content ? <AIMessageMarkdown isStreaming={status === "streaming"}>{result.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div>;
|
||
if (!resultUrls.length) return <div className="storyboard-workbench-empty"><Film />没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||
const note = result.settings ? directorNoteFor(mode, result.settings) : "";
|
||
return <>
|
||
{mode === "video" ? <button type="button" className="creation-video-result" onClick={() => openPreview(resultUrls[0], "video")} aria-label="预览生成视频"><video muted preload="metadata" className="size-full object-cover" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => openPreview(url, "image")} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||
{note ? <p className="storyboard-workbench-director-note"><span>导演手记</span>{note}</p> : null}
|
||
<div className="storyboard-workbench-media-meta"><span>{mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成变体</button><Link to={canvasPath}>{canvasHandoffAvailable ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||
</>;
|
||
}
|
||
|
||
function videoResolutionLabel(value: string | number) {
|
||
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
|
||
}
|
||
|
||
function formatMessageTime(value: string) {
|
||
const timestamp = new Date(value).getTime();
|
||
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
|
||
}
|
||
|
||
function conversationPreviewMessage(conversation: CreationConversation) {
|
||
let fallback: CreationMessage | undefined;
|
||
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
|
||
const message = conversation.messages[index];
|
||
if (!message.content.trim()) continue;
|
||
fallback ||= message;
|
||
if (message.role === "user") return message;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
async function buildTextMessageContent(item: CreationMessage) {
|
||
const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []);
|
||
const attachments = item.attachments || [];
|
||
if (!attachments.length) return content;
|
||
const parts: AiTextContentPart[] = [{ type: "text", text: content }];
|
||
for (const attachment of attachments) {
|
||
if (isImageAttachment(attachment)) {
|
||
parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } });
|
||
continue;
|
||
}
|
||
const url = await creationAttachmentDataUrl(attachment);
|
||
parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } });
|
||
}
|
||
return parts;
|
||
}
|
||
|
||
async function creationAttachmentDataUrl(attachment: CreationAttachment) {
|
||
if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`);
|
||
const attachmentUrl = attachment.url || "";
|
||
if (attachmentUrl.startsWith("data:")) return attachmentUrl;
|
||
const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null;
|
||
if (blob) {
|
||
if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`);
|
||
return blobToDataUrl(blob);
|
||
}
|
||
if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl;
|
||
throw new Error(`${attachment.name} 无法读取,请重新上传后再试`);
|
||
}
|
||
|
||
function blobToDataUrl(blob: Blob) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ""));
|
||
reader.onerror = () => reject(reader.error || new Error("附件读取失败"));
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } {
|
||
return creationAttachmentKind(attachment) === "video";
|
||
}
|
||
|
||
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } {
|
||
return creationAttachmentKind(attachment) === "image";
|
||
}
|
||
|
||
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
|
||
|
||
function attachCreationTaskContexts(tasks: GenerationTask[], conversations: CreationConversation[]) {
|
||
const contexts = new Map<string, { prompt: string; clientContext: NonNullable<GenerationTask["clientContext"]> }>();
|
||
for (const conversation of conversations) {
|
||
for (const [messageIndex, message] of conversation.messages.entries()) {
|
||
if (message.role !== "assistant" || !message.taskIds?.length) continue;
|
||
const prompt = conversation.messages[messageIndex - 1]?.role === "user" ? conversation.messages[messageIndex - 1].content : "";
|
||
for (const [batchIndex, taskId] of message.taskIds.entries()) contexts.set(taskId, { prompt, clientContext: { conversationId: conversation.id, messageId: message.id, batchIndex, batchCount: message.taskIds.length } });
|
||
}
|
||
}
|
||
return tasks.map((task) => {
|
||
const context = contexts.get(task.id);
|
||
return context ? { ...task, prompt: context.prompt, clientContext: context.clientContext } : task;
|
||
});
|
||
}
|
||
|
||
async function materializeCreationTaskResults(tasks: GenerationTask[], signal?: AbortSignal): Promise<PersistedCreationTask[]> {
|
||
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
|
||
if (task.status !== "succeeded" || !task.clientContext) return task;
|
||
try {
|
||
const materialized = await runGenerationConsumer(signal, (managedSignal) => materializeGenerationTaskAssets(task, managedSignal));
|
||
const creationResultUrls = generationTaskMaterializedUrls(materialized);
|
||
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
|
||
} catch (error) {
|
||
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
|
||
}
|
||
}));
|
||
}
|
||
|
||
function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: PersistedCreationTask[]) {
|
||
let changed = false;
|
||
const next = conversations.map((conversation) => {
|
||
let conversationChanged = false;
|
||
let completedAt = conversation.updatedAt;
|
||
const messages = conversation.messages.map((message) => {
|
||
if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return message;
|
||
const taskIds = new Set(message.taskIds || []);
|
||
const matches = tasks
|
||
.filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id))
|
||
.sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0));
|
||
const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0));
|
||
if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message;
|
||
|
||
const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls)));
|
||
const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
|
||
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
|
||
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
|
||
conversationChanged = true;
|
||
changed = true;
|
||
|
||
if (resultUrls.length) {
|
||
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
|
||
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
|
||
}
|
||
if (matches.every((task) => task.status === "cancelled")) {
|
||
const localOnly = matches.find(isLocalDreaminaWaitStopped);
|
||
return { ...message, status: "cancelled" as const, content: localOnly ? localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds };
|
||
}
|
||
const failed = matches.find((task) => task.status === "failed" || task.creationError);
|
||
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };
|
||
});
|
||
return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation;
|
||
});
|
||
return changed ? next : conversations;
|
||
}
|
||
|
||
function creationTaskResultUrls(task: PersistedCreationTask) {
|
||
if (task.creationResultUrls?.length) return task.creationResultUrls;
|
||
return [];
|
||
}
|
||
|
||
function conversationTimestamp(value: string) {
|
||
const timestamp = new Date(value).getTime();
|
||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||
}
|
||
|
||
function formatConversationTime(value: string) {
|
||
const timestamp = conversationTimestamp(value);
|
||
if (!timestamp) return "时间未知";
|
||
return conversationTimeFormatter.format(timestamp);
|
||
}
|
||
|
||
function ratioPreviewStyle(value: string) {
|
||
const [width, height] = value.replace("x", ":").split(":").map(Number);
|
||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 14, height: 14 };
|
||
const scale = Math.min(28 / width, 20 / height);
|
||
return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) };
|
||
}
|