Files
NianAIGC/components/create-studio.tsx
2026-05-29 15:54:13 +08:00

609 lines
25 KiB
TypeScript
Raw Permalink 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 } 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<StudioMode>(initialMode);
const [promptByMode, setPromptByMode] = useState<Record<GenerateMode, string>>({
image: "",
video: ""
});
const [materials, setMaterials] = useState<PromptMaterial[]>([]);
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 [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<MentionState | null>(null);
const [activeMentionIndex, setActiveMentionIndex] = useState<number | null>(null);
const [promptScrollTop, setPromptScrollTop] = 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 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(null);
}, [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<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;
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 (
<div className={clsx("create-studio", isImageEditMode && "enhance-studio")} ref={studioRef}>
<div className="workspace-head" data-animate>
<div>
<h1 className="workspace-title"></h1>
</div>
</div>
<div className="create-mode-bar" data-animate>
<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={busy || !prompt.trim()}
onClick={submit}
aria-label={generateMode === "image" ? "生成图片" : "生成视频"}
title={generateMode === "image" ? "生成图片" : "生成视频"}
>
{busy ? <Loader2 className="spin" size={18} /> : <Send size={18} />}
<span className="create-submit-label">{generateMode === "image" ? "生成图片" : "生成视频"}</span>
</button>
</div>
) : null}
</div>
{isImageEditMode ? (
<div ref={(node) => { modePanelRef.current = node; }} data-animate>
<ImageEditor initialMode={mode} hideModeSwitch />
</div>
) : (
<section className="panel" ref={(node) => { modePanelRef.current = node; }} data-animate>
{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="输入营销内容提示词,键入 @ 选择已上传素材"
/>
{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>
{materials.length ? (
<>
<div className="material-board prompt-material-board" aria-label="已上传素材" ref={materialBoardRef}>
{visibleMaterials.map((material, index) => (
<article
className={clsx("material-card", material.type, material.label && prompt.includes(material.label) ? "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">{material.label && prompt.includes(material.label) ? "已引用" : "未引用"}</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="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" ? "生成质量" : "文本影响"}</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>
) : (
<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>
<label className="toggle-line">
<input type="checkbox" checked={forceSingle} onChange={(event) => setForceSingle(event.target.checked)} />
<span></span>
</label>
</>
) : (
<>
<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>
<div className="field inline-field">
<label htmlFor="videoDuration"></label>
<select
id="videoDuration"
value={videoDuration}
onChange={(event) => setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false }))}
>
{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)}>
{VIDEO_RESOLUTIONS.map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)}
</select>
</div>
</>
)}
</div>
</section>
)}
</div>
);
}
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 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 labelFor(type: MaterialKind, index: number): string {
if (type === "video") return `@视频${index}`;
if (type === "audio") return `@音频${index}`;
return `@图片${index}`;
}
function relabel(items: PromptMaterial[]): PromptMaterial[] {
const counts: Record<MaterialKind, number> = { image: 0, video: 0, audio: 0 };
return items.map((item) => {
counts[item.type] += 1;
return { ...item, label: labelFor(item.type, counts[item.type]) };
});
}
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 normalizeMentionText(value: string): string {
return value.replace(/^@/, "").trim().toLowerCase();
}
function renderPromptTokenLayer(prompt: string, materials: PromptMaterial[]) {
const materialByLabel = new Map(materials.map((material) => [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 "图片";
}