Files
NianAIGC/components/create-studio.tsx
2026-07-14 12:59:07 +08:00

1780 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import { Check, Download, ExternalLink, Film, ImageIcon, ImagePlus, ImageUp, Loader2, Music, Paintbrush, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react";
import clsx from "clsx";
import { ImageEditor, type ImageEditMode } from "@/components/image-editor";
import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import { VIDEO_DURATION_DEFAULT, VIDEO_DURATION_OPTIONS, VIDEO_RATIOS, VIDEO_RESOLUTIONS, clampVideoDuration } from "@/lib/video-settings";
import type { Asset, GenerationJob } from "@/lib/types";
import type { PromptMaterial } from "@/lib/prompt/assembler";
import {
detectMaterialDraftStart,
insertMaterialDraftToken,
nextMaterialDraftToken,
normalizeMaterialDraftToken,
type MaterialDraftKind
} from "@/lib/prompt/material-draft";
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
type GenerateMode = "image" | "video";
type StudioMode = GenerateMode | ImageEditMode;
type MaterialKind = PromptMaterial["type"];
type ImageGenerateEngine = "jimeng" | "evolink" | "bailian";
type VideoGenerateEngine = "seedance" | "bailian";
type MentionState = {
start: number;
query: string;
};
type MaterialDraftTarget = "prompt" | "template";
type MaterialDraftState = {
target: MaterialDraftTarget;
index: number;
query: string;
activeIndex: number | null;
};
type MaterialDraftOption = {
token: string;
label: string;
caption: string;
type: MaterialKind;
};
type HealthCapability = {
id: string;
engine?: string;
};
type ImageTemplate = {
id: string;
name: string;
prompt: string;
previewImageUrl?: string;
settings: {
engine?: ImageGenerateEngine;
width?: number;
height?: number;
forceSingle?: boolean;
scale?: number;
quality?: string;
};
sortOrder: number;
updatedAt?: string;
};
type TemplateForm = {
name: string;
prompt: string;
previewImageUrl: string;
engine: ImageGenerateEngine;
jimengInfluence: string;
evolinkQuality: string;
size: string;
sortOrder: string;
};
const jimengInfluenceOptions = [
{ id: "creative", label: "创意 35", scale: 35 },
{ id: "balanced", label: "均衡 50", scale: 50 },
{ id: "precise", label: "贴合 70", scale: 70 },
{ id: "strict", label: "严格 85", scale: 85 }
];
const evolinkQualityOptions = [
{ id: "low", label: "快速", quality: "low" },
{ id: "medium", label: "标准", quality: "medium" },
{ id: "high", label: "精细", quality: "high" }
];
const imageSizePresets = [
{ label: "1:1", width: 2048, height: 2048 },
{ label: "4:3", width: 2304, height: 1728 },
{ label: "16:9", width: 2560, height: 1440 },
{ label: "9:16", width: 1440, height: 2560 }
];
type ImageSizePreset = (typeof imageSizePresets)[number];
type TemplateConsoleSnapshot = {
mode: GenerateMode;
imagePrompt: string;
imageSize: ImageSizePreset;
imageEngine: ImageGenerateEngine;
jimengInfluence: string;
evolinkQuality: string;
};
const materialUploadAccept = "image/*,video/*,audio/*";
const templatePreviewAccept = "image/*";
const MATERIAL_PAGE_SIZE = 6;
const defaultTemplateForm: TemplateForm = {
name: "",
prompt: "",
previewImageUrl: "",
engine: "jimeng",
jimengInfluence: jimengInfluenceOptions[1].id,
evolinkQuality: evolinkQualityOptions[1].id,
size: imageSizePresets[0].label,
sortOrder: "0"
};
export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMode }) {
const [mode, setMode] = useState<StudioMode>(initialMode);
const [promptByMode, setPromptByMode] = useState<Record<GenerateMode, string>>({
image: "",
video: ""
});
const [materials, setMaterials] = useState<PromptMaterial[]>([]);
const [imageTemplates, setImageTemplates] = useState<ImageTemplate[]>([]);
const [taskAssets, setTaskAssets] = useState<Asset[]>([]);
const [recentJobs, setRecentJobs] = useState<GenerationJob[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [tasksLoading, setTasksLoading] = useState(false);
const [templateSaving, setTemplateSaving] = useState(false);
const [templatePreviewUploading, setTemplatePreviewUploading] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
const [templateEditorOpen, setTemplateEditorOpen] = useState(false);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [previewTemplate, setPreviewTemplate] = useState<ImageTemplate | null>(null);
const [previewTaskAsset, setPreviewTaskAsset] = useState<Asset | null>(null);
const [durationNow, setDurationNow] = useState(() => Date.now());
const [templateForm, setTemplateForm] = useState<TemplateForm>(defaultTemplateForm);
const [templateRestoreState, setTemplateRestoreState] = useState<TemplateConsoleSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [imageSize, setImageSize] = useState(imageSizePresets[0]);
const [imageEngine, setImageEngine] = useState<ImageGenerateEngine>("jimeng");
const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id);
const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id);
const [videoEngine, setVideoEngine] = useState<VideoGenerateEngine>("seedance");
const [videoRatio, setVideoRatio] = useState("9:16");
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
const [videoResolution, setVideoResolution] = useState("720p");
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [materialDraft, setMaterialDraft] = useState<MaterialDraftState | null>(null);
const [activeMentionIndex, setActiveMentionIndex] = useState<number | null>(null);
const [promptScrollTop, setPromptScrollTop] = useState(0);
const [templatePromptScrollTop, setTemplatePromptScrollTop] = useState(0);
const [materialPage, setMaterialPage] = useState(1);
const studioRef = useRef<HTMLDivElement | null>(null);
const modePanelRef = useRef<HTMLDivElement | HTMLElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const materialBoardRef = useRef<HTMLDivElement | null>(null);
const promptRef = useRef<HTMLTextAreaElement | null>(null);
const templatePromptRef = useRef<HTMLTextAreaElement | null>(null);
const materialDraftInputRef = useRef<HTMLInputElement | null>(null);
const isImageEditMode = mode === "inpaint" || mode === "upscale";
const generateMode: GenerateMode = mode === "video" ? "video" : "image";
const prompt = promptByMode[generateMode];
const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1];
const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1];
const selectedTemplateJimengInfluence = jimengInfluenceOptions.find((option) => option.id === templateForm.jimengInfluence) || jimengInfluenceOptions[1];
const selectedTemplateEvolinkQuality = evolinkQualityOptions.find((option) => option.id === templateForm.evolinkQuality) || evolinkQualityOptions[1];
const templateFormReady = Boolean(templateForm.name.trim() && templateForm.prompt.trim() && templateForm.previewImageUrl.trim());
const templateFormPlaceholders = useMemo(() => extractMaterialPlaceholders(templateForm.prompt), [templateForm.prompt]);
const materialPlaceholders = useMemo(() => extractMaterialPlaceholders(prompt), [prompt]);
const referencedMaterialLabels = useMemo(() => new Set(materialPlaceholders.map((placeholder) => placeholder.token)), [materialPlaceholders]);
const missingMaterialPlaceholders = useMemo(() => {
const existing = new Set(materials.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label)));
return materialPlaceholders.filter((placeholder) => !existing.has(placeholder.token));
}, [materialPlaceholders, materials]);
const submitDisabled = busy || !prompt.trim() || missingMaterialPlaceholders.length > 0;
const submitTitle = missingMaterialPlaceholders.length
? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}`
: generateMode === "image" ? "生成图片" : "生成视频";
const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE);
const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE;
const mentionSuggestions = useMemo(() => {
if (!mentionState) return [];
return materials.filter((material) => materialMatchesMention(material, mentionState.query)).slice(0, 8);
}, [materials, mentionState]);
const materialDraftOptions = useMemo(() => {
if (!materialDraft) return [];
return buildMaterialDraftOptions({
target: materialDraft.target,
query: materialDraft.query,
prompt: materialDraft.target === "template" ? templateForm.prompt : prompt,
materials
});
}, [materialDraft, materials, prompt, templateForm.prompt]);
const taskAssetById = useMemo(() => {
const map = new Map<string, Asset>();
for (const asset of taskAssets) map.set(asset.id, asset);
return map;
}, [taskAssets]);
const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]);
useEffect(() => {
setActiveMentionIndex(null);
}, [mentionState?.query, mentionSuggestions.length]);
useEffect(() => {
if (!materialDraft) return;
window.requestAnimationFrame(() => materialDraftInputRef.current?.focus());
}, [materialDraft?.target, materialDraft?.index]);
useEffect(() => {
setMaterialPage((page) => clampPage(page, materials.length, MATERIAL_PAGE_SIZE));
}, [materials.length]);
useEffect(() => {
return runScopedMotion(studioRef, (scope) => revealChildren(scope));
}, []);
useEffect(() => {
crossfadeIn(modePanelRef.current);
}, [mode]);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error, notice]);
useEffect(() => {
if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card");
}, [materials.length, materialPage]);
useEffect(() => {
let active = true;
void fetch("/api/health")
.then((response) => response.ok ? response.json() : null)
.then((payload: { capabilities?: HealthCapability[] } | null) => {
const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine;
if (active && (engine === "evolink" || engine === "jimeng" || engine === "bailian")) setImageEngine(engine);
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
void loadImageTemplates(() => active);
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
void loadTaskModuleData(() => active);
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") {
void loadTaskModuleData(() => active, { silent: true });
}
}, 15000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
if (!hasLiveTasks) return undefined;
setDurationNow(Date.now());
const timer = window.setInterval(() => {
setDurationNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [hasLiveTasks]);
async function loadImageTemplates(isActive: () => boolean = () => true) {
setTemplatesLoading(true);
setTemplateError(null);
try {
const response = await fetch("/api/image-templates", { cache: "no-store" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "读取模板失败");
const nextTemplates = ((payload.templates || []) as ImageTemplate[]).sort(sortTemplates);
if (isActive()) {
setImageTemplates(nextTemplates);
}
} catch (err) {
if (isActive()) setTemplateError(err instanceof Error ? err.message : String(err));
} finally {
if (isActive()) setTemplatesLoading(false);
}
}
async function loadTaskModuleData(isActive: () => boolean = () => true, options: { silent?: boolean } = {}) {
if (!options.silent) setTasksLoading(true);
setTasksError(null);
try {
const [assetResponse, imageResponse, videoResponse] = await Promise.all([
fetch("/api/assets", { cache: "no-store" }),
fetch("/api/generations/image", { cache: "no-store" }),
fetch("/api/generations/video", { cache: "no-store" })
]);
const [assetPayload, imagePayload, videoPayload] = await Promise.all([
assetResponse.json(),
imageResponse.json(),
videoResponse.json()
]);
if (!assetResponse.ok) throw new Error(assetPayload.error || "读取资产失败");
if (!imageResponse.ok) throw new Error(imagePayload.error || "读取图片任务失败");
if (!videoResponse.ok) throw new Error(videoPayload.error || "读取视频任务失败");
const dedupedJobs = new Map<string, GenerationJob>();
for (const job of [...(imagePayload.jobs || []), ...(videoPayload.jobs || [])] as GenerationJob[]) {
dedupedJobs.set(job.id, job);
}
const nextJobs = [...dedupedJobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (isActive()) {
setTaskAssets((assetPayload.assets || []) as Asset[]);
setRecentJobs(nextJobs);
}
} catch (err) {
if (isActive()) setTasksError(err instanceof Error ? err.message : String(err));
} finally {
if (isActive() && !options.silent) setTasksLoading(false);
}
}
function setPrompt(value: string) {
setPromptByMode((items) => ({ ...items, [generateMode]: value }));
}
function handlePromptInput(value: string, cursor: number) {
const draft = detectMaterialDraftStart(prompt, value, cursor);
if (draft) {
setPrompt(draft.text);
startMaterialDraft("prompt", draft.index);
return;
}
setPrompt(value);
updateMention(value, cursor);
}
function updateMention(value: string, cursor: number) {
setMentionState(findMentionAtCursor(value, cursor));
}
async function uploadAssetFiles(files: File[]): Promise<Asset[]> {
const formData = new FormData();
for (const file of files) formData.append("files", file);
const response = await fetch("/api/assets/upload", {
method: "POST",
body: formData
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "上传失败");
return payload.assets as Asset[];
}
async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind } = {}) {
const selectedFiles = Array.from(files || []);
const expectedType = options.expectedType;
if (!selectedFiles.length) return;
if (expectedType && selectedFiles.some((file) => !fileMatchesMaterialKind(file, expectedType))) {
setError(`请上传${shortTypeName(expectedType)}素材。`);
return;
}
setUploading(true);
setError(null);
setNotice(null);
try {
const uploaded = await uploadAssetFiles(selectedFiles);
if (expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== expectedType)) {
throw new Error(`请上传${shortTypeName(expectedType)}素材。`);
}
setMaterials((items) => {
const next = [...items];
uploaded.forEach((asset, index) => {
const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined;
const material = materialFromAsset(asset, next, targetLabel);
const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1;
if (existingIndex >= 0) {
next[existingIndex] = material;
return;
}
next.push(material);
});
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setUploading(false);
}
}
async function uploadTemplatePreview(files: FileList | null) {
const file = files?.[0];
if (!file) return;
if (!fileMatchesMaterialKind(file, "image")) {
setTemplateError("模板预览图只能上传图片。");
return;
}
setTemplatePreviewUploading(true);
setTemplateError(null);
try {
const [asset] = await uploadAssetFiles([file]);
setTemplateForm((form) => ({
...form,
previewImageUrl: asset.url,
name: form.name || asset.name.replace(/\.[^.]+$/, "")
}));
} catch (err) {
setTemplateError(err instanceof Error ? err.message : String(err));
} finally {
setTemplatePreviewUploading(false);
}
}
function materialFromAsset(asset: Asset, existing: PromptMaterial[], preferredLabel?: string): PromptMaterial {
const type = materialTypeForAsset(asset);
const normalizedPreferredLabel = normalizeMaterialLabel(preferredLabel);
return {
id: asset.id,
url: asset.url,
type,
role: "upload",
label: normalizedPreferredLabel && materialTypeForLabel(normalizedPreferredLabel) === type
? normalizedPreferredLabel
: nextMaterialLabel(type, existing),
name: asset.name
};
}
function removeMaterial(index: number) {
setMaterials((items) => items.filter((_, itemIndex) => itemIndex !== index));
}
function insertToken(token?: string) {
if (!token) return;
insertAtCursor(token);
}
function insertAtCursor(text: string) {
const textarea = promptRef.current;
if (!textarea) {
const joiner = prompt.endsWith("\n") || !prompt.trim() ? "" : "\n";
setPrompt(`${prompt}${joiner}${text}`);
return;
}
const start = textarea.selectionStart ?? prompt.length;
const end = textarea.selectionEnd ?? prompt.length;
const before = prompt.slice(0, start);
const after = prompt.slice(end);
const needsLeadingSpace = before.length > 0 && !/[\s(【\[]$/.test(before);
const needsTrailingSpace = after.length > 0 && !/^[\s)】\]]/.test(after);
const insertion = `${needsLeadingSpace ? " " : ""}${text}${needsTrailingSpace ? " " : ""}`;
const nextPrompt = `${before}${insertion}${after}`;
const nextCursor = before.length + insertion.length;
setPrompt(nextPrompt);
window.requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(nextCursor, nextCursor);
});
}
function applyImageTemplate(template: ImageTemplate) {
if (activeTemplateId === template.id) {
clearImageTemplateSelection();
return;
}
if (!templateRestoreState) {
setTemplateRestoreState({
mode: generateMode,
imagePrompt: promptByMode.image,
imageSize,
imageEngine,
jimengInfluence,
evolinkQuality
});
}
applyTemplateToConsole(template, `已应用模板:${template.name}`);
}
function clearImageTemplateSelection() {
const snapshot = templateRestoreState;
setActiveTemplateId(null);
setTemplateRestoreState(null);
setMaterialDraft(null);
setMentionState(null);
setPromptScrollTop(0);
setError(null);
if (snapshot) {
setMode(snapshot.mode);
setPromptByMode((items) => ({ ...items, image: snapshot.imagePrompt }));
setImageSize(snapshot.imageSize);
setImageEngine(snapshot.imageEngine);
setJimengInfluence(snapshot.jimengInfluence);
setEvolinkQuality(snapshot.evolinkQuality);
} else {
setPromptByMode((items) => ({ ...items, image: "" }));
setImageSize(imageSizePresets[0]);
setImageEngine("jimeng");
setJimengInfluence(jimengInfluenceOptions[1].id);
setEvolinkQuality(evolinkQualityOptions[1].id);
}
setNotice("已取消模板选择");
}
function applyTemplateToConsole(template: ImageTemplate, message: string) {
setMode("image");
setPromptByMode((items) => ({ ...items, image: template.prompt }));
setActiveTemplateId(template.id);
setMaterialDraft(null);
const templateEngine = normalizeImageEngine(template.settings.engine);
if (templateEngine) setImageEngine(templateEngine);
const preset = imageSizePresets.find((item) => item.width === template.settings.width && item.height === template.settings.height);
if (preset) setImageSize(preset);
const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale);
if (templateInfluence) setJimengInfluence(templateInfluence.id);
const templateQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality);
if (templateQuality) setEvolinkQuality(templateQuality.id);
setMentionState(null);
setPromptScrollTop(0);
setNotice(message);
setError(null);
}
function resetTemplateForm() {
setTemplateForm(defaultTemplateFormForEngine(imageEngine));
setEditingTemplateId(null);
setTemplateError(null);
setMaterialDraft(null);
setTemplatePromptScrollTop(0);
}
function openTemplateCreator() {
resetTemplateForm();
setTemplateEditorOpen(true);
}
function openTemplateEditor(template: ImageTemplate) {
setTemplateForm(formFromTemplate(template));
setEditingTemplateId(template.id);
setTemplateError(null);
setMaterialDraft(null);
setTemplatePromptScrollTop(0);
setTemplateEditorOpen(true);
}
function closeTemplateEditor() {
setTemplateEditorOpen(false);
setEditingTemplateId(null);
setTemplateError(null);
setMaterialDraft(null);
setTemplatePromptScrollTop(0);
}
async function saveTemplate() {
const isEditing = Boolean(editingTemplateId);
const endpoint = editingTemplateId ? `/api/image-templates/${encodeURIComponent(editingTemplateId)}` : "/api/image-templates";
setTemplateSaving(true);
setTemplateError(null);
try {
const response = await fetch(endpoint, {
method: isEditing ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(templatePayloadFromForm(templateForm))
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "保存模板失败");
const saved = payload.template as ImageTemplate;
setImageTemplates((items) => {
const rest = items.filter((item) => item.id !== saved.id);
return [...rest, saved].sort(sortTemplates);
});
setTemplateForm(formFromTemplate(saved));
setEditingTemplateId(null);
setTemplateEditorOpen(false);
applyTemplateToConsole(saved, isEditing ? "模板已更新并应用。" : "模板已保存并应用。");
} catch (err) {
setTemplateError(err instanceof Error ? err.message : String(err));
} finally {
setTemplateSaving(false);
}
}
function insertTemplatePlaceholder(type: MaterialKind) {
const placeholders = extractMaterialPlaceholders(templateForm.prompt).filter((placeholder) => placeholder.type === type);
const nextIndex = placeholders.reduce((max, placeholder) => Math.max(max, placeholder.index), 0) + 1;
const token = labelFor(type, nextIndex);
const textarea = templatePromptRef.current;
const value = templateForm.prompt;
if (!textarea) {
const joiner = value.endsWith("\n") || !value.trim() ? "" : " ";
setTemplateForm((form) => ({ ...form, prompt: `${value}${joiner}${token} ` }));
return;
}
const start = textarea.selectionStart ?? value.length;
const end = textarea.selectionEnd ?? value.length;
const before = value.slice(0, start);
const after = value.slice(end);
const needsLeadingSpace = before.length > 0 && !/[\s(【\[]$/.test(before);
const insertion = `${needsLeadingSpace ? " " : ""}${token} `;
const nextPrompt = `${before}${insertion}${after}`;
const nextCursor = before.length + insertion.length;
setTemplateForm((form) => ({ ...form, prompt: nextPrompt }));
window.requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(nextCursor, nextCursor);
});
}
function startMaterialDraft(target: MaterialDraftTarget, index: number) {
setMentionState(null);
setMaterialDraft({ target, index, query: "", activeIndex: null });
}
function updateMaterialDraftQuery(value: string) {
setMaterialDraft((draft) => draft ? { ...draft, query: value.replace(/^@+/, ""), activeIndex: null } : draft);
}
function confirmMaterialDraft(option?: MaterialDraftOption) {
if (!materialDraft) return;
const sourcePrompt = materialDraft.target === "template" ? templateForm.prompt : prompt;
const token = option?.token || normalizeMaterialDraftToken(materialDraft.query, sourcePrompt);
if (!token) return;
const next = insertMaterialDraftToken(sourcePrompt, materialDraft.index, token);
const target = materialDraft.target;
setMaterialDraft(null);
setMentionState(null);
if (target === "template") {
setTemplateForm((form) => ({ ...form, prompt: next.text }));
window.requestAnimationFrame(() => {
templatePromptRef.current?.focus();
templatePromptRef.current?.setSelectionRange(next.cursor, next.cursor);
});
return;
}
setPrompt(next.text);
window.requestAnimationFrame(() => {
promptRef.current?.focus();
promptRef.current?.setSelectionRange(next.cursor, next.cursor);
});
}
function handleMaterialDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (!materialDraft) return;
if (event.key === "Escape") {
event.preventDefault();
const target = materialDraft.target;
setMaterialDraft(null);
window.requestAnimationFrame(() => {
(target === "template" ? templatePromptRef.current : promptRef.current)?.focus();
});
return;
}
if (event.key === "ArrowDown" && materialDraftOptions.length) {
event.preventDefault();
setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? 0 : (draft.activeIndex + 1) % materialDraftOptions.length } : draft);
return;
}
if (event.key === "ArrowUp" && materialDraftOptions.length) {
event.preventDefault();
setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? materialDraftOptions.length - 1 : (draft.activeIndex - 1 + materialDraftOptions.length) % materialDraftOptions.length } : draft);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
confirmMaterialDraft(materialDraftOptions[materialDraft.activeIndex ?? 0]);
}
}
function handleTemplatePromptInput(value: string, cursor: number) {
const draft = detectMaterialDraftStart(templateForm.prompt, value, cursor);
if (draft) {
setTemplateForm((form) => ({ ...form, prompt: draft.text }));
startMaterialDraft("template", draft.index);
return;
}
setTemplateForm((form) => ({ ...form, prompt: value }));
}
function selectMention(material: PromptMaterial) {
if (!material.label) return;
const textarea = promptRef.current;
const end = textarea?.selectionStart ?? prompt.length;
const start = mentionState?.start ?? end;
const before = prompt.slice(0, start);
const after = prompt.slice(end);
const needsTrailingSpace = after.length > 0 && !/^[\s)】\]]/.test(after);
const insertion = `${material.label}${needsTrailingSpace ? " " : ""}`;
const nextPrompt = `${before}${insertion}${after}`;
const nextCursor = before.length + insertion.length;
setPrompt(nextPrompt);
setMentionState(null);
window.requestAnimationFrame(() => {
textarea?.focus();
textarea?.setSelectionRange(nextCursor, nextCursor);
});
}
function handlePromptKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (!mentionState) return;
if (event.key === "Escape") {
setMentionState(null);
return;
}
if (!mentionSuggestions.length) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveMentionIndex((index) => index === null ? 0 : (index + 1) % mentionSuggestions.length);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveMentionIndex((index) => index === null ? mentionSuggestions.length - 1 : (index - 1 + mentionSuggestions.length) % mentionSuggestions.length);
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectMention(mentionSuggestions[activeMentionIndex ?? 0] || mentionSuggestions[0]);
}
}
async function submit() {
if (isImageEditMode) return;
if (missingMaterialPlaceholders.length) {
setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}`);
return;
}
setBusy(true);
setError(null);
setNotice(null);
try {
const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video";
if (generateMode === "video" && videoEngine === "bailian") {
const imageCount = materials.filter((material) => material.type === "image").length;
if (imageCount < 1 || imageCount > 2 || materials.some((material) => material.type !== "image")) {
throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。");
}
}
const body = generateMode === "image"
? {
capability: "image.generate",
engine: imageEngine,
prompt,
materials: materials.filter((material) => material.type === "image"),
width: imageSize.width,
height: imageSize.height,
...(imageEngine === "evolink"
? { quality: selectedEvolinkQuality.quality }
: { scale: selectedJimengInfluence.scale }),
force_single: true
}
: {
engine: videoEngine,
prompt,
materials,
settings: {
...(videoEngine === "seedance" ? { ratio: videoRatio } : {}),
duration: videoDuration,
resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution
}
};
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "提交生成失败");
const submittedJob = payload.job as GenerationJob | undefined;
if (submittedJob?.id) {
setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
}
void loadTaskModuleData(() => true, { silent: true });
setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入「结果」,生成完成后结果资产会自动保留。`);
setPrompt("");
setActiveTemplateId(null);
setMaterials([]);
setMaterialPage(1);
setMentionState(null);
setPromptScrollTop(0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
function renderCreateModeBar() {
return (
<div className="create-mode-bar">
<div className="segmented mode-switch" aria-label="创作类型">
<button type="button" className={clsx(mode === "image" && "active")} aria-pressed={mode === "image"} onClick={() => setMode("image")}>
<ImagePlus size={17} />
</button>
<button type="button" className={clsx(mode === "video" && "active")} aria-pressed={mode === "video"} onClick={() => setMode("video")}>
<Film size={17} />
</button>
<button type="button" className={clsx(mode === "inpaint" && "active")} aria-pressed={mode === "inpaint"} onClick={() => setMode("inpaint")}>
<Paintbrush size={17} />
</button>
<button type="button" className={clsx(mode === "upscale" && "active")} aria-pressed={mode === "upscale"} onClick={() => setMode("upscale")}>
<ImageUp size={17} />
</button>
</div>
{!isImageEditMode ? (
<div className="toolbar create-actions">
<button
className="button primary create-submit-button"
type="button"
disabled={submitDisabled}
onClick={submit}
aria-label={generateMode === "image" ? "生成图片" : "生成视频"}
title={submitTitle}
>
{busy ? <Loader2 className="spin" size={18} /> : <Send size={18} />}
<span className="create-submit-label">{generateMode === "image" ? "生成图片" : "生成视频"}</span>
</button>
</div>
) : null}
</div>
);
}
return (
<div className={clsx("create-studio", isImageEditMode && "enhance-studio")} ref={studioRef}>
{isImageEditMode ? (
<div className="create-workbench-layout" ref={(node) => { modePanelRef.current = node; }} data-animate>
<section className="panel create-workbench-panel">
{renderCreateModeBar()}
<ImageEditor initialMode={mode} hideModeSwitch />
</section>
</div>
) : (
<div className="create-workbench-layout with-template-column" ref={(node) => { modePanelRef.current = node; }} data-animate>
<aside className="panel image-template-column" aria-label="模板选择">
<div className="image-template-rail-head">
<h2></h2>
{templatesLoading ? <Loader2 className="spin" size={16} /> : null}
</div>
<button className="button template-add-button" type="button" onClick={openTemplateCreator} disabled={templateSaving}>
<Plus size={16} />
</button>
{templateError && !templateEditorOpen ? <div className="callout compact-callout" role="alert">{templateError}</div> : null}
<div className="image-template-rail-list">
{imageTemplates.length ? (
imageTemplates.map((template) => (
<article
className={clsx("image-template-rail-card", activeTemplateId === template.id && "active")}
key={template.id}
title={template.name}
>
<button
className="image-template-preview-button"
type="button"
title="放大预览"
aria-label={`放大预览 ${template.name}`}
onClick={() => setPreviewTemplate(template)}
>
{renderImageTemplatePreview(template)}
</button>
<div className="image-template-card-footer">
<div className="image-template-card-copy">
<strong>{template.name}</strong>
</div>
<button
className="image-template-edit-button"
type="button"
title="编辑模板"
aria-label={`编辑模板 ${template.name}`}
onClick={() => openTemplateEditor(template)}
>
<Pencil size={14} />
</button>
<button
className="image-template-select-button"
type="button"
onClick={() => applyImageTemplate(template)}
title={`选择模板 ${template.name}`}
aria-pressed={activeTemplateId === template.id}
>
{activeTemplateId === template.id ? <Check size={14} /> : <Plus size={14} />}
{activeTemplateId === template.id ? "取消选择" : "选择模板"}
</button>
</div>
</article>
))
) : !templatesLoading && !templateError ? (
<div className="template-empty compact"></div>
) : null}
</div>
</aside>
<section className="panel create-workbench-panel create-center-panel">
<div className="create-center-head">
<h2></h2>
{renderCreateModeBar()}
</div>
<div className="create-workbench-grid">
<div className="create-main-column">
{error || notice ? (
<div className="studio-messages top-studio-messages" ref={feedbackRef}>
{error ? <div className="callout" role="alert">{error}</div> : null}
{notice ? <div className="callout success-callout" role="status" aria-live="polite">{notice}</div> : null}
</div>
) : null}
<div className="field prompt-field">
<div className="prompt-label-row">
<label htmlFor="createPrompt">{generateMode === "image" ? "图片提示词" : "视频提示词"}</label>
<label className="icon-button prompt-upload-button" title="上传素材" aria-label="上传素材">
{uploading ? <Loader2 className="spin" size={18} /> : <Upload size={18} />}
<input
type="file"
multiple
accept={materialUploadAccept}
onChange={(event) => {
void uploadFiles(event.target.files);
event.currentTarget.value = "";
}}
/>
</label>
</div>
<div className="prompt-editor-wrap">
<div className="prompt-token-layer" aria-hidden="true">
<div style={{ transform: `translateY(-${promptScrollTop}px)` }}>
{renderPromptTokenLayer(prompt, materials)}
</div>
</div>
<textarea
id="createPrompt"
ref={promptRef}
value={prompt}
onChange={(event) => {
handlePromptInput(event.target.value, event.target.selectionStart);
}}
onInput={(event) => handlePromptInput(event.currentTarget.value, event.currentTarget.selectionStart)}
onClick={(event) => updateMention(event.currentTarget.value, event.currentTarget.selectionStart)}
onKeyDown={handlePromptKeyDown}
onKeyUp={(event) => updateMention(event.currentTarget.value, event.currentTarget.selectionStart)}
onScroll={(event) => setPromptScrollTop(event.currentTarget.scrollTop)}
placeholder="输入营销内容提示词,键入 @ 选择已上传素材"
/>
{materialDraft?.target === "prompt" ? renderMaterialDraftPanel(
materialDraft,
materialDraftOptions,
materialDraftInputRef,
updateMaterialDraftQuery,
handleMaterialDraftKeyDown,
confirmMaterialDraft
) : null}
{!materialDraft && mentionState && mentionSuggestions.length ? (
<div className="mention-popover">
{mentionSuggestions.map((material, index) => (
<button
className={clsx("mention-option", index === activeMentionIndex && "active")}
type="button"
key={`${material.label}-${material.url}`}
onMouseDown={(event) => {
event.preventDefault();
selectMention(material);
}}
>
{renderMaterialPreview(material, "tiny")}
<span className="chip-token">{material.label}</span>
<span className="chip-name">{material.name || material.url}</span>
<span className="mention-kind">{shortTypeName(material.type)}</span>
</button>
))}
</div>
) : null}
</div>
{missingMaterialPlaceholders.length ? (
<div className="material-placeholder-board" aria-label="待上传模板素材">
{missingMaterialPlaceholders.map((placeholder) => (
<label className={clsx("material-placeholder-card", placeholder.type, uploading && "uploading")} key={placeholder.token} title={`上传${placeholder.token}`}>
<span className="material-placeholder-icon" aria-hidden="true">
{uploading ? <Loader2 className="spin" size={18} /> : <Upload size={18} />}
</span>
<span className="material-placeholder-copy">
<span className="chip-token">{placeholder.token}</span>
<span className="material-placeholder-state">{shortTypeName(placeholder.type)}</span>
</span>
<input
type="file"
accept={acceptForMaterialKind(placeholder.type)}
disabled={uploading}
onChange={(event) => {
void uploadFiles(event.target.files, { targetLabel: placeholder.token, expectedType: placeholder.type });
event.currentTarget.value = "";
}}
/>
</label>
))}
</div>
) : null}
{materials.length ? (
<>
<div className="material-board prompt-material-board" aria-label="已上传素材" ref={materialBoardRef}>
{visibleMaterials.map((material, index) => (
<article
className={clsx("material-card", material.type, materialIsReferenced(material, referencedMaterialLabels) ? "referenced" : "missing")}
key={`${material.url}-${materialPageOffset + index}`}
>
<button
className="material-card-main"
type="button"
title={`${material.label || ""} ${material.name || material.url}`}
onClick={() => insertToken(material.label)}
>
{renderMaterialPreview(material, "large")}
<span className="material-card-copy">
<span className="material-card-head">
<span className="chip-token">{material.label}</span>
<span className="material-state">{materialIsReferenced(material, referencedMaterialLabels) ? "已引用" : "未引用"}</span>
</span>
<span className="chip-name">{material.name || material.url}</span>
</span>
</button>
<button
className="material-remove"
type="button"
title="移除素材"
onClick={(event) => {
event.stopPropagation();
removeMaterial(materialPageOffset + index);
}}
>
<X size={14} aria-hidden="true" />
</button>
</article>
))}
</div>
<Pagination page={materialPage} pageSize={MATERIAL_PAGE_SIZE} total={materials.length} label="素材分页" onPageChange={setMaterialPage} />
</>
) : null}
</div>
<div className="inline-settings">
{generateMode === "image" ? (
<>
<div className="field inline-field">
<label htmlFor="imageEngine"></label>
<select id="imageEngine" value={imageEngine} onChange={(event) => setImageEngine(normalizeImageEngine(event.target.value) || "jimeng")}>
<option value="jimeng"></option>
<option value="evolink">Image2</option>
<option value="bailian"></option>
</select>
</div>
<div className="field inline-field">
<label htmlFor="imageSize"></label>
<select
id="imageSize"
value={imageSize.label}
onChange={(event) => setImageSize(imageSizePresets.find((item) => item.label === event.target.value) || imageSizePresets[0])}
>
{imageSizePresets.map((preset) => <option key={preset.label}>{preset.label}</option>)}
</select>
</div>
<div className="field inline-field">
<label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : imageEngine === "bailian" ? "生成模式" : "文本影响"}</label>
{imageEngine === "evolink" ? (
<select id="imageEngineTuning" value={evolinkQuality} onChange={(event) => setEvolinkQuality(event.target.value)}>
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
) : imageEngine === "bailian" ? (
<select id="imageEngineTuning" value="thinking" disabled><option value="thinking"></option></select>
) : (
<select id="imageEngineTuning" value={jimengInfluence} onChange={(event) => setJimengInfluence(event.target.value)}>
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
)}
</div>
</>
) : (
<>
<div className="field inline-field">
<label htmlFor="videoEngine"></label>
<select id="videoEngine" value={videoEngine} onChange={(event) => setVideoEngine(event.target.value === "bailian" ? "bailian" : "seedance")}>
<option value="seedance">Seedance</option>
<option value="bailian"></option>
</select>
</div>
{videoEngine === "seedance" ? (
<div className="field inline-field">
<label htmlFor="videoRatio"></label>
<select id="videoRatio" value={videoRatio} onChange={(event) => setVideoRatio(event.target.value)}>
{VIDEO_RATIOS.map((ratio) => (
<option key={ratio} value={ratio}>{ratio === "adaptive" ? "自适应" : ratio}</option>
))}
</select>
</div>
) : null}
<div className="field inline-field">
<label htmlFor="videoDuration"></label>
<select
id="videoDuration"
value={videoDuration}
onChange={(event) => setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false }))}
>
{(videoEngine === "bailian" ? Array.from({ length: 14 }, (_, index) => index + 2) : VIDEO_DURATION_OPTIONS).map((seconds) => <option key={seconds} value={seconds}>{seconds} </option>)}
</select>
</div>
<div className="field inline-field">
<label htmlFor="videoResolution"></label>
<select id="videoResolution" value={videoResolution} onChange={(event) => setVideoResolution(event.target.value)}>
{(videoEngine === "bailian" ? ["720p", "1080p"] : VIDEO_RESOLUTIONS).map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)}
</select>
</div>
</>
)}
</div>
</div>
{templateEditorOpen ? (
<div className="template-editor-backdrop" role="dialog" aria-modal="true" aria-label={editingTemplateId ? "编辑模板" : "添加模板"}>
<div className="template-editor-dialog">
<div className="template-editor-head">
<div className="template-editor-title">
<span className="template-editor-mark" aria-hidden="true">
<ImagePlus size={18} />
</span>
<h2>{editingTemplateId ? "编辑模板" : "添加模板"}</h2>
</div>
<button
className="icon-button"
type="button"
title="关闭"
aria-label="关闭"
onClick={closeTemplateEditor}
disabled={templateSaving}
>
<X size={16} />
</button>
</div>
{templateError ? <div className="callout compact-callout" role="alert">{templateError}</div> : null}
<div className="template-editor-body">
<aside className="template-editor-preview-pane">
<div className="template-preview-pane-head">
<span></span>
<span className={clsx("template-required-pill", templateForm.previewImageUrl && "complete")}>
{templateForm.previewImageUrl ? "已上传" : "必填"}
</span>
</div>
<label className={clsx("template-preview-uploader", templateForm.previewImageUrl && "has-image", templatePreviewUploading && "uploading")}>
<span className="template-preview-uploader-media" aria-hidden="true">
{templatePreviewUploading ? <Loader2 className="spin" size={22} /> : templateForm.previewImageUrl ? <img src={templateForm.previewImageUrl} alt="" /> : <Upload size={22} />}
</span>
<span>{templateForm.previewImageUrl ? "重新上传" : "点击上传"}</span>
<input
type="file"
accept={templatePreviewAccept}
disabled={templatePreviewUploading}
onChange={(event) => {
void uploadTemplatePreview(event.target.files);
event.currentTarget.value = "";
}}
/>
</label>
<div className="template-preview-meta" aria-label="模板参数预览">
<span>{imageEngineLabel(templateForm.engine)}</span>
<span>{templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : selectedTemplateJimengInfluence.label}</span>
<span>{templateForm.size}</span>
</div>
</aside>
<div className="template-inline-form template-editor-form">
<label className="field">
<span></span>
<input value={templateForm.name} onChange={(event) => setTemplateForm((form) => ({ ...form, name: event.target.value }))} />
</label>
<div className="field template-engine-field">
<span></span>
<div className="segmented template-engine-choice" aria-label="生图引擎">
<button
type="button"
className={clsx(templateForm.engine === "jimeng" && "active")}
aria-pressed={templateForm.engine === "jimeng"}
onClick={() => setTemplateForm((form) => ({ ...form, engine: "jimeng" }))}
>
</button>
<button
type="button"
className={clsx(templateForm.engine === "evolink" && "active")}
aria-pressed={templateForm.engine === "evolink"}
onClick={() => setTemplateForm((form) => ({ ...form, engine: "evolink" }))}
>
Image2
</button>
</div>
</div>
<label className="field">
<span>{templateForm.engine === "evolink" ? "生成质量" : "文本影响"}</span>
{templateForm.engine === "evolink" ? (
<select value={templateForm.evolinkQuality} onChange={(event) => setTemplateForm((form) => ({ ...form, evolinkQuality: event.target.value }))}>
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
) : (
<select value={templateForm.jimengInfluence} onChange={(event) => setTemplateForm((form) => ({ ...form, jimengInfluence: event.target.value }))}>
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
)}
</label>
<label className="field">
<span></span>
<select value={templateForm.size} onChange={(event) => setTemplateForm((form) => ({ ...form, size: event.target.value }))}>
{imageSizePresets.map((preset) => <option value={preset.label} key={preset.label}>{preset.label}</option>)}
</select>
</label>
<label className="field">
<span></span>
<input type="number" value={templateForm.sortOrder} onChange={(event) => setTemplateForm((form) => ({ ...form, sortOrder: event.target.value }))} />
</label>
<label className="field template-prompt-field">
<span></span>
<div className="template-placeholder-tools" aria-label="插入占位符">
<button type="button" onClick={() => insertTemplatePlaceholder("image")}>
<Plus size={13} />
</button>
<button type="button" onClick={() => insertTemplatePlaceholder("video")}>
<Plus size={13} />
</button>
<button type="button" onClick={() => insertTemplatePlaceholder("audio")}>
<Plus size={13} />
</button>
</div>
<div className="prompt-editor-wrap template-prompt-editor-wrap">
<div className="prompt-token-layer" aria-hidden="true">
<div style={{ transform: `translateY(-${templatePromptScrollTop}px)` }}>
{renderPromptTokenLayer(templateForm.prompt, [])}
</div>
</div>
<textarea
ref={templatePromptRef}
value={templateForm.prompt}
onChange={(event) => handleTemplatePromptInput(event.target.value, event.target.selectionStart)}
onInput={(event) => handleTemplatePromptInput(event.currentTarget.value, event.currentTarget.selectionStart)}
onScroll={(event) => setTemplatePromptScrollTop(event.currentTarget.scrollTop)}
rows={7}
placeholder="例如:以 @图片1 为主体,生成干净高级的商品主视觉"
/>
{materialDraft?.target === "template" ? renderMaterialDraftPanel(
materialDraft,
materialDraftOptions,
materialDraftInputRef,
updateMaterialDraftQuery,
handleMaterialDraftKeyDown,
confirmMaterialDraft
) : null}
</div>
</label>
{templateFormPlaceholders.length ? (
<div className="template-placeholder-strip" aria-label="模板占位符">
{templateFormPlaceholders.map((placeholder) => (
<span className="template-placeholder-chip" key={placeholder.token}>
{placeholder.token}
</span>
))}
</div>
) : null}
</div>
</div>
<div className="template-editor-actions">
<button className="button" type="button" onClick={closeTemplateEditor} disabled={templateSaving}>
</button>
<button className="button primary" type="button" onClick={() => void saveTemplate()} disabled={templateSaving || templatePreviewUploading || !templateFormReady}>
{templateSaving ? <Loader2 className="spin" size={18} /> : <Save size={18} />}
{editingTemplateId ? "保存修改" : "保存模板"}
</button>
</div>
</div>
</div>
) : null}
{previewTemplate ? (
<div className="template-preview-backdrop" role="dialog" aria-modal="true" aria-label="模板预览">
<div className="template-preview-dialog">
<div className="template-preview-dialog-head">
<h2>{previewTemplate.name}</h2>
<button className="icon-button" type="button" title="关闭" aria-label="关闭" onClick={() => setPreviewTemplate(null)}>
<X size={16} />
</button>
</div>
<div className="template-preview-dialog-media">
{previewTemplate.previewImageUrl ? <img src={previewTemplate.previewImageUrl} alt="" /> : <ImagePlus size={40} />}
</div>
<div className="template-preview-dialog-actions">
{previewTemplate.previewImageUrl ? (
<a className="button" href={previewTemplate.previewImageUrl} download>
<Download size={16} />
</a>
) : null}
<button className="button" type="button" onClick={() => {
const template = previewTemplate;
setPreviewTemplate(null);
openTemplateEditor(template);
}}>
<Pencil size={16} />
</button>
<button className="button primary" type="button" onClick={() => {
applyImageTemplate(previewTemplate);
setPreviewTemplate(null);
}}>
</button>
</div>
</div>
</div>
) : null}
{previewTaskAsset ? (
<div className="asset-preview-backdrop" role="presentation" onMouseDown={() => setPreviewTaskAsset(null)}>
<div className="asset-preview-dialog task-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${previewTaskAsset.name}`} onMouseDown={(event) => event.stopPropagation()}>
<div className="asset-preview-head">
<div>
<strong>{previewTaskAsset.name}</strong>
<span className="muted compact-hint"></span>
</div>
<div className="asset-preview-actions">
<a className="icon-button" href={assetDownloadUrl(previewTaskAsset)} title="下载" aria-label={`下载 ${previewTaskAsset.name}`}>
<Download size={18} />
</a>
<button className="icon-button" type="button" title="关闭预览" aria-label="关闭预览" onClick={() => setPreviewTaskAsset(null)}>
<X size={18} />
</button>
</div>
</div>
<div className="asset-preview-media">
{renderTaskAssetPreviewLarge(previewTaskAsset)}
</div>
</div>
</div>
) : null}
</div>
</section>
<aside className="panel create-task-column" aria-label="任务模块">
<div className="create-task-head">
<div>
<h2></h2>
<span>{recentJobs.length ? `${recentJobs.length} 个任务` : "任务列表"}</span>
</div>
<button
className="icon-button"
type="button"
title="刷新任务"
aria-label="刷新任务"
onClick={() => void loadTaskModuleData()}
disabled={tasksLoading}
>
{tasksLoading ? <Loader2 className="spin" size={16} /> : <RefreshCw size={16} />}
</button>
</div>
{tasksError ? <div className="callout compact-callout" role="alert">{tasksError}</div> : null}
<div className="create-task-list">
{recentJobs.map((job) => (
<article className="create-task-card" key={job.id}>
{renderTaskThumbnail(job, taskAssetById, setPreviewTaskAsset)}
<div className="create-task-body">
<h3 title={taskName(job)}>{taskName(job)}</h3>
<div className="create-task-meta">
<span className={`status ${job.status}`}>{statusLabel(job.status)}</span>
<span>{durationLabel(job, durationNow)}</span>
</div>
</div>
<a className="button mini-button create-task-link" href={taskDetailHref(job)}>
<ExternalLink size={14} />
</a>
</article>
))}
{!recentJobs.length && !tasksLoading && !tasksError ? (
<div className="template-empty compact"></div>
) : null}
</div>
</aside>
</div>
)}
</div>
);
}
function renderTaskThumbnail(job: GenerationJob, assetById: Map<string, Asset>, onPreview: (asset: Asset) => void) {
const asset = job.outputAssetIds.map((assetId) => assetById.get(assetId)).find((item): item is Asset => Boolean(item));
if (!asset) {
return (
<div className={clsx("create-task-thumb create-task-thumb-placeholder", isPendingTask(job) && "generating")} aria-hidden="true">
{isPendingTask(job) ? <Loader2 className="spin" size={18} /> : <ImageIcon size={18} />}
<span>{isPendingTask(job) ? "生成中" : "无结果"}</span>
</div>
);
}
const isVideo = asset.kind === "video" || materialTypeForAsset(asset) === "video";
const isPreviewable = !isVideo;
const media = isVideo
? <video src={asset.url} muted playsInline preload="metadata" />
: <img src={asset.url} alt="" />;
if (isPreviewable) {
return (
<button className="create-task-thumb create-task-thumb-button" type="button" onClick={() => onPreview(asset)} aria-label={`查看大图 ${asset.name}`}>
{media}
</button>
);
}
return <div className="create-task-thumb" aria-hidden="true">{media}</div>;
}
function renderTaskAssetPreviewLarge(asset: Asset) {
if (asset.kind === "video" || materialTypeForAsset(asset) === "video") {
return <video src={asset.url} controls playsInline />;
}
return <img src={asset.url} alt={asset.name} />;
}
function assetDownloadUrl(asset: Asset) {
return `/api/assets/${encodeURIComponent(asset.id)}/download`;
}
function isPendingTask(job: GenerationJob) {
return job.status === "queued" || job.status === "running";
}
function taskName(job: GenerationJob) {
const text = job.prompt?.trim();
return text ? text : capabilityLabel(job.capability);
}
function taskDetailHref(job: GenerationJob) {
return `/assets?view=tasks&taskId=${encodeURIComponent(job.id)}`;
}
function statusLabel(status: GenerationJob["status"]) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
if (status === "succeeded") return "已完成";
if (status === "failed") return "失败";
if (status === "expired") return "已过期";
if (status === "cancelled") return "已取消";
return status;
}
function isTerminalStatus(status: GenerationJob["status"]) {
return status === "succeeded" || status === "failed" || status === "expired" || status === "cancelled";
}
function capabilityLabel(capability?: GenerationJob["capability"]) {
if (capability === "image.generate") return "图片生成";
if (capability === "image.inpaint") return "局部重绘";
if (capability === "image.upscale") return "智能超清";
if (capability === "video.generate") return "视频生成";
return "生成任务";
}
function durationLabel(job: GenerationJob, now: number) {
const terminal = isTerminalStatus(job.status);
return `${terminal ? "耗时" : "已耗时"} ${durationValue(job, now)}`;
}
function durationValue(job: GenerationJob, now: number) {
const start = new Date(job.createdAt).getTime();
const terminal = isTerminalStatus(job.status);
const end = terminal ? new Date(job.updatedAt).getTime() : now;
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
return formatDuration(end - start);
}
function formatDuration(ms: number) {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours) return `${hours}小时${minutes}`;
if (minutes) return `${minutes}${seconds}`;
return `${seconds}`;
}
function renderMaterialPreview(material: PromptMaterial, size: "normal" | "tiny" | "large" = "normal") {
return (
<span className={clsx("material-preview", size, material.type)} aria-hidden="true">
{material.type === "image" ? <img src={material.url} alt="" /> : null}
{material.type === "video" ? <video src={material.url} muted playsInline preload="metadata" /> : null}
{material.type === "audio" ? <Music size={size === "tiny" ? 13 : size === "large" ? 20 : 15} /> : null}
</span>
);
}
function formFromTemplate(template: ImageTemplate): TemplateForm {
const engine = normalizeImageEngine(template.settings.engine) || "jimeng";
const jimengInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale)?.id || jimengInfluenceOptions[1].id;
const evolinkQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality)?.id || evolinkQualityOptions[1].id;
return {
name: template.name,
prompt: template.prompt,
previewImageUrl: template.previewImageUrl || "",
engine,
jimengInfluence,
evolinkQuality,
size: sizeLabelFromSettings(template.settings.width, template.settings.height),
sortOrder: String(template.sortOrder ?? 0)
};
}
function templatePayloadFromForm(form: TemplateForm) {
const size = imageSizePresets.find((preset) => preset.label === form.size) || imageSizePresets[0];
const selectedInfluence = jimengInfluenceOptions.find((option) => option.id === form.jimengInfluence) || jimengInfluenceOptions[1];
const selectedQuality = evolinkQualityOptions.find((option) => option.id === form.evolinkQuality) || evolinkQualityOptions[1];
return {
name: form.name,
prompt: form.prompt,
previewImageUrl: form.previewImageUrl,
settings: {
engine: form.engine,
width: size.width,
height: size.height,
forceSingle: true,
...(form.engine === "evolink"
? { quality: selectedQuality.quality }
: { scale: selectedInfluence.scale })
},
sortOrder: Number(form.sortOrder || 0)
};
}
function defaultTemplateFormForEngine(engine: ImageGenerateEngine): TemplateForm {
return {
...defaultTemplateForm,
engine,
jimengInfluence: jimengInfluenceOptions[1].id,
evolinkQuality: evolinkQualityOptions[1].id
};
}
function sizeLabelFromSettings(width?: number, height?: number) {
return imageSizePresets.find((preset) => preset.width === width && preset.height === height)?.label || imageSizePresets[0].label;
}
function normalizeImageEngine(value?: string): ImageGenerateEngine | undefined {
if (value === "jimeng" || value === "evolink" || value === "bailian") return value;
return undefined;
}
function imageEngineLabel(engine: ImageGenerateEngine) {
return engine === "evolink" ? "Image2" : engine === "bailian" ? "百炼" : "即梦";
}
function sortTemplates(a: ImageTemplate, b: ImageTemplate) {
const order = (a.sortOrder || 0) - (b.sortOrder || 0);
if (order !== 0) return order;
return (b.updatedAt || "").localeCompare(a.updatedAt || "");
}
function renderImageTemplatePreview(template: ImageTemplate) {
return (
<span className="image-template-preview" aria-hidden="true">
{template.previewImageUrl ? <img src={template.previewImageUrl} alt="" /> : <ImagePlus size={22} />}
</span>
);
}
function renderMaterialDraftPanel(
draft: MaterialDraftState,
options: MaterialDraftOption[],
inputRef: RefObject<HTMLInputElement | null>,
onQueryChange: (value: string) => void,
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void,
onConfirm: (option?: MaterialDraftOption) => void
) {
return (
<div className="material-draft-panel" role="group" aria-label="素材占位输入">
<div className="material-draft-entry">
<span className="material-draft-prefix" aria-hidden="true">@</span>
<input
ref={inputRef}
value={draft.query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder="图片1 / 视频1 / 音频1"
aria-label="素材占位名称"
/>
<button className="material-draft-confirm" type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => onConfirm(options[draft.activeIndex ?? 0])}>
</button>
</div>
{options.length ? (
<div className="material-draft-options" aria-label="素材占位候选">
{options.map((option, index) => (
<button
className={clsx("material-draft-option", index === (draft.activeIndex ?? 0) && "active")}
type="button"
key={`${option.token}-${option.caption}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onConfirm(option)}
>
<span className="chip-token">{option.token}</span>
<span>{option.label}</span>
<span>{option.caption}</span>
</button>
))}
</div>
) : null}
</div>
);
}
function materialTypeForAsset(asset: Asset): MaterialKind {
const contentType = typeof asset.metadata.contentType === "string" ? asset.metadata.contentType.toLowerCase() : "";
const url = asset.url.toLowerCase();
if (asset.kind === "video" || contentType.startsWith("video/") || /\.(mp4|mov|webm)(\?|$)/.test(url)) return "video";
if (contentType.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac)(\?|$)/.test(url)) return "audio";
return "image";
}
function fileMatchesMaterialKind(file: File, type: MaterialKind): boolean {
const contentType = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type === "image") return contentType.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif)(\?|$)/.test(name);
if (type === "video") return contentType.startsWith("video/") || /\.(mp4|mov|webm)(\?|$)/.test(name);
return contentType.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac)(\?|$)/.test(name);
}
function acceptForMaterialKind(type: MaterialKind): string {
if (type === "video") return "video/*";
if (type === "audio") return "audio/*";
return "image/*";
}
function labelFor(type: MaterialKind, index: number): string {
if (type === "video") return `@视频${index}`;
if (type === "audio") return `@音频${index}`;
return `@图片${index}`;
}
function nextMaterialLabel(type: MaterialKind, existing: PromptMaterial[]): string {
const used = new Set(existing.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label)));
let index = 1;
while (used.has(labelFor(type, index))) index += 1;
return labelFor(type, index);
}
function normalizeMaterialLabel(label?: string): string | undefined {
const match = label?.trim().match(/^@(图片|图|视频|音频)(\d+)$/);
if (!match) return undefined;
const type = match[1] === "视频" ? "video" : match[1] === "音频" ? "audio" : "image";
return labelFor(type, Number(match[2]));
}
function materialTypeForLabel(label: string): MaterialKind | undefined {
const normalized = normalizeMaterialLabel(label);
if (!normalized) return undefined;
if (normalized.startsWith("@视频")) return "video";
if (normalized.startsWith("@音频")) return "audio";
return "image";
}
function materialIsReferenced(material: PromptMaterial, referencedLabels: Set<string>): boolean {
const label = normalizeMaterialLabel(material.label);
return Boolean(label && referencedLabels.has(label));
}
function findMentionAtCursor(value: string, cursor: number): MentionState | null {
const beforeCursor = value.slice(0, cursor);
const start = beforeCursor.lastIndexOf("@");
if (start === -1) return null;
const query = beforeCursor.slice(start + 1);
if (query.length > 16 || /[\s\n\r、,.!?()[\]{}<>《》"'“”]/.test(query)) return null;
if (/^(图片|图|视频|音频)\d*$/.test(query)) return null;
return { start, query };
}
function materialMatchesMention(material: PromptMaterial, query: string): boolean {
const normalizedQuery = normalizeMentionText(query);
if (!normalizedQuery) return true;
return [
material.label,
material.label?.replace(/^@/, ""),
material.name,
shortTypeName(material.type),
material.type
].filter(Boolean).some((value) => normalizeMentionText(String(value)).includes(normalizedQuery));
}
function buildMaterialDraftOptions(input: { target: MaterialDraftTarget; query: string; prompt: string; materials: PromptMaterial[] }): MaterialDraftOption[] {
const options: MaterialDraftOption[] = [];
if (input.target === "prompt") {
for (const material of input.materials) {
const token = normalizeMaterialLabel(material.label);
if (!token || !materialMatchesMention(material, input.query)) continue;
options.push({
token,
label: material.name || token,
caption: shortTypeName(material.type),
type: material.type
});
if (options.length >= 5) break;
}
}
for (const type of ["image", "video", "audio"] as MaterialDraftKind[]) {
const token = nextMaterialDraftToken(type, input.prompt);
const option = {
token,
label: `${shortTypeName(type)}占位`,
caption: input.target === "template" ? "模板素材" : "待上传素材",
type
};
if (materialDraftOptionMatches(option, input.query)) options.push(option);
}
return uniqueMaterialDraftOptions(options).slice(0, 8);
}
function materialDraftOptionMatches(option: MaterialDraftOption, query: string): boolean {
const normalizedQuery = normalizeMentionText(query);
if (!normalizedQuery) return true;
return [option.token, option.token.replace(/^@/, ""), option.label, option.caption, shortTypeName(option.type), option.type]
.some((value) => normalizeMentionText(value).includes(normalizedQuery));
}
function uniqueMaterialDraftOptions(options: MaterialDraftOption[]): MaterialDraftOption[] {
const seen = new Set<string>();
return options.filter((option) => {
if (seen.has(option.token)) return false;
seen.add(option.token);
return true;
});
}
function normalizeMentionText(value: string): string {
return value.replace(/^@/, "").trim().toLowerCase();
}
function renderPromptTokenLayer(prompt: string, materials: PromptMaterial[]) {
const materialByLabel = new Map(materials.map((material) => [normalizeMaterialLabel(material.label) || material.label, material]));
const nodes: ReactNode[] = [];
let cursor = 0;
for (const match of prompt.matchAll(/@(图片|图|视频|音频)(\d+)/g)) {
const index = match.index ?? 0;
if (index > cursor) nodes.push(prompt.slice(cursor, index));
const token = match[1] === "图" ? `@图片${match[2]}` : match[0];
const material = materialByLabel.get(token);
nodes.push(
<span className={clsx("prompt-token-card", material ? "linked" : "unbound")} key={`${token}-${index}`}>
{token}
</span>
);
cursor = index + match[0].length;
}
if (cursor < prompt.length) nodes.push(prompt.slice(cursor));
return nodes.length ? nodes : prompt;
}
function shortTypeName(type: MaterialKind) {
if (type === "video") return "视频";
if (type === "audio") return "音频";
return "图片";
}