609 lines
25 KiB
TypeScript
609 lines
25 KiB
TypeScript
"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(0);
|
||
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(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<HTMLTextAreaElement>) {
|
||
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 (
|
||
<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 "图片";
|
||
}
|