"use client"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { Film, ImagePlus, ImageUp, Loader2, Music, Paintbrush, 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 } from "@/lib/types"; import type { PromptMaterial } from "@/lib/prompt/assembler"; type GenerateMode = "image" | "video"; type StudioMode = GenerateMode | ImageEditMode; type MaterialKind = PromptMaterial["type"]; type ImageGenerateEngine = "jimeng" | "evolink"; type MentionState = { start: number; query: string; }; type HealthCapability = { id: string; engine?: 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 } ]; const materialUploadAccept = "image/*,video/*,audio/*"; const MATERIAL_PAGE_SIZE = 6; export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMode }) { const [mode, setMode] = useState(initialMode); const [promptByMode, setPromptByMode] = useState>({ image: "", video: "" }); const [materials, setMaterials] = useState([]); const [busy, setBusy] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [imageSize, setImageSize] = useState(imageSizePresets[0]); const [imageEngine, setImageEngine] = useState("jimeng"); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); const [forceSingle, setForceSingle] = useState(true); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); const [mentionState, setMentionState] = useState(null); const [activeMentionIndex, setActiveMentionIndex] = useState(0); const [promptScrollTop, setPromptScrollTop] = useState(0); const [materialPage, setMaterialPage] = useState(1); const studioRef = useRef(null); const modePanelRef = useRef(null); const feedbackRef = useRef(null); const materialBoardRef = useRef(null); const promptRef = useRef(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 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]); useEffect(() => { setActiveMentionIndex(0); }, [mentionState?.query, mentionSuggestions.length]); 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")) setImageEngine(engine); }) .catch(() => undefined); return () => { active = false; }; }, []); function setPrompt(value: string) { setPromptByMode((items) => ({ ...items, [generateMode]: value })); } function handlePromptInput(value: string, cursor: number) { setPrompt(value); updateMention(value, cursor); } function updateMention(value: string, cursor: number) { setMentionState(findMentionAtCursor(value, cursor)); } async function uploadFiles(files: FileList | null) { if (!files?.length) return; setUploading(true); setError(null); setNotice(null); try { const formData = new FormData(); for (const file of Array.from(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 || "上传失败"); const uploaded = payload.assets as Asset[]; setMaterials((items) => { const next = [...items]; for (const asset of uploaded) next.push(materialFromAsset(asset, next)); return next; }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setUploading(false); } } function materialFromAsset(asset: Asset, existing: PromptMaterial[]): PromptMaterial { const type = materialTypeForAsset(asset); const currentCount = existing.filter((material) => material.type === type).length; return { id: asset.id, url: asset.url, type, role: "upload", label: labelFor(type, currentCount + 1), name: asset.name }; } function removeMaterial(index: number) { setMaterials((items) => relabel(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 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) { if (!mentionState) return; if (event.key === "Escape") { setMentionState(null); return; } if (!mentionSuggestions.length) return; if (event.key === "ArrowDown") { event.preventDefault(); setActiveMentionIndex((index) => (index + 1) % mentionSuggestions.length); } if (event.key === "ArrowUp") { event.preventDefault(); setActiveMentionIndex((index) => (index - 1 + mentionSuggestions.length) % mentionSuggestions.length); } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); selectMention(mentionSuggestions[activeMentionIndex] || mentionSuggestions[0]); } } async function submit() { if (isImageEditMode) return; setBusy(true); setError(null); setNotice(null); try { const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video"; const body = generateMode === "image" ? { capability: "image.generate", prompt, materials: materials.filter((material) => material.type === "image"), width: imageSize.width, height: imageSize.height, ...(imageEngine === "evolink" ? { quality: selectedEvolinkQuality.quality } : { scale: selectedJimengInfluence.scale }), force_single: forceSingle } : { prompt, materials, settings: { ratio: videoRatio, duration: videoDuration, resolution: 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 || "提交生成失败"); setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入「结果」,生成完成后结果资产会自动保留。`); setPrompt(""); setMaterials([]); setMaterialPage(1); setMentionState(null); setPromptScrollTop(0); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } return (

创作生成台

{!isImageEditMode ? (
) : null}
{isImageEditMode ? (
{ modePanelRef.current = node; }} data-animate>
) : (
{ modePanelRef.current = node; }} data-animate> {error || notice ? (
{error ?
{error}
: null} {notice ?
{notice}
: null}
) : null}