增加复制粘贴交互

This commit is contained in:
andy
2026-09-30 13:22:53 +08:00
parent 4195525dc7
commit deb9cc5bcf
14 changed files with 1144 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Music, Pencil, Plus, RefreshCw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react";
import clsx from "clsx";
import { clampPage, pageItems, Pagination } from "@/components/pagination";
@@ -32,6 +32,7 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"
import { formatBillingAmount } from "@/lib/billing";
import { assetContentUrl, assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls";
import { createSeedreamMarkedFile } from "@/lib/client/seedream-markup";
import { clipboardImageFiles, clipboardImageURL, insertClipboardImageReferences } from "@/lib/client/clipboard-images";
import {
buildSeedreamInteractivePrompt,
buildSeedreamLayerPrompt,
@@ -50,6 +51,21 @@ type MaterialKind = PromptMaterial["type"];
type ImageGenerateEngine = "jimeng" | "evolink" | "bailian" | "seedream";
type VideoGenerateEngine = "seedance" | "bailian" | "minimax";
type MaterialUploadOptions = {
targetLabel?: string;
expectedType?: MaterialKind;
seedreamSourceMode?: "single" | "append";
maxFiles?: number;
replaceAll?: boolean;
importedURL?: boolean;
paste?: {
mode: GenerateMode;
contextKey: string;
insertReferences: boolean;
selection?: { text: string; start: number; end: number };
};
};
type MentionState = {
start: number;
query: string;
@@ -180,7 +196,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
image: "",
video: ""
});
const [materials, setMaterials] = useState<PromptMaterial[]>([]);
const [materials, setMaterialState] = useState<PromptMaterial[]>([]);
const materialsRef = useRef<PromptMaterial[]>([]);
const uploadingRef = useRef(false);
const [imageTemplates, setImageTemplates] = useState<ImageTemplate[]>([]);
const [taskAssets, setTaskAssets] = useState<Asset[]>([]);
const [recentJobs, setRecentJobs] = useState<GenerationJob[]>([]);
@@ -242,6 +260,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
const evolinkModelChosenRef = useRef(false);
const generateMode: GenerateMode = mode === "video" ? "video" : "image";
const pasteContextKey = [generateMode, imageCreationMode, imageEngine, evolinkModel, videoEngine, videoModel, activeTemplateId, templateEditorOpen, previewTemplate?.id, taskDetailJobId].join("|");
const pasteContextRef = useRef(pasteContextKey);
pasteContextRef.current = pasteContextKey;
const prompt = promptByMode[generateMode];
const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1];
const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1];
@@ -316,8 +337,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
&& missingMaterialPlaceholders.length === 0
&& missingAnnotationPlaceholders.length === 0
&& minimaxMaterialsValid;
const submitDisabled = busy || !generationInputReady;
const submitTitle = !minimaxMaterialsValid
const submitDisabled = busy || uploading || !generationInputReady;
const submitTitle = uploading ? "素材上传或导入中,请稍候"
: !minimaxMaterialsValid
? "MiniMax H3 第一版仅支持 0 或 1 张图片素材"
: unsupportedSpecializedPlaceholders.length
? `交互编辑和图层拆分不支持 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}`
@@ -576,6 +598,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
setPromptByMode((items) => ({ ...items, [generateMode]: value }));
}
// Keep the latest list available to asynchronous uploads without putting
// side effects inside a React state updater (which can run more than once).
function setMaterials(value: PromptMaterial[] | ((items: PromptMaterial[]) => PromptMaterial[])) {
const next = typeof value === "function" ? value(materialsRef.current) : value;
materialsRef.current = next;
setMaterialState(next);
}
function selectImageCreationMode(nextMode: SeedreamCreationMode) {
setImageCreationMode(nextMode);
setMentionState(null);
@@ -651,10 +681,87 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "上传失败");
if (!Array.isArray(payload.assets) || payload.assets.length !== files.length) {
throw new Error("上传结果不完整,请刷新素材后重试。");
}
return payload.assets as Asset[];
}
async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind; seedreamSourceMode?: "single" | "append"; maxFiles?: number; replaceAll?: boolean } = {}) {
function handleImagePaste(event: ClipboardEvent<HTMLElement>) {
if (templateEditorOpen || previewTemplate || taskDetailJobId) return;
const files = clipboardImageFiles(event.clipboardData);
const imageURL = files.length ? null : clipboardImageURL(event.clipboardData);
if (!files.length && !imageURL) return; // Ordinary text and mixed text/URLs keep native paste behavior.
// A URL is only a candidate: do not fetch it or intercept text paste without consent.
// Show only the hostname, never a signed query string or credentials.
if (imageURL && !window.confirm(`是否将来自 ${new URL(imageURL).hostname} 的链接导入为图片素材?\n确定后由服务器下载并保存图片;取消则按普通文字粘贴。`)) return;
event.preventDefault();
event.stopPropagation();
if (uploadingRef.current || busy) {
setError(uploadingRef.current ? "素材正在上传或导入,请稍后再粘贴。" : "任务正在提交,请稍后再粘贴。");
return;
}
const options: MaterialUploadOptions = { expectedType: "image" };
if (specializedImageMode) {
options.seedreamSourceMode = imageCreationMode === "interactive" ? "append" : "single";
if (options.seedreamSourceMode === "append" && seedreamInteractiveSourceKeys.length >= 10) {
setError("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。");
return;
}
if (imageCreationMode === "layers") {
if (files[0] && !isSeedreamLayerInputFile(files[0])) {
setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。");
return;
}
if (selectedSeedreamSource && !window.confirm("替换当前待拆分图片吗?当前标注会清空,原图片仍保留在素材中。")) return;
}
} else if (minimaxVideoMode) {
options.maxFiles = 1;
options.replaceAll = true;
if (materials.length && !window.confirm("MiniMax H3 只支持一张首帧图,是否替换当前素材?")) return;
} else if ((generateMode === "image" && imageEngine === "seedream") || (generateMode === "video" && videoEngine === "bailian")) {
const limit = generateMode === "image" ? 10 : 2;
const remaining = Math.max(0, limit - materials.filter((material) => material.type === "image").length);
if (!remaining) {
setError(generateMode === "image" ? "Seedream 5.0 Pro 最多支持 10 张参考图,请先移除图片。" : "百炼图生视频最多支持 2 张图片,请先移除图片。");
return;
}
options.maxFiles = remaining;
}
const textarea = event.target === promptRef.current ? promptRef.current : null;
options.paste = {
mode: generateMode,
contextKey: pasteContextKey,
// Workspace inputs are selected directly and have their own sequential
// image numbering; don't turn an empty automatic-split prompt into text.
insertReferences: !specializedImageMode,
selection: textarea ? { text: prompt, start: textarea.selectionStart, end: textarea.selectionEnd } : undefined
};
if (imageURL) {
options.importedURL = true;
void receiveMaterialAssets(() => importImageURL(imageURL), options, 1);
} else {
void uploadFiles(files, options);
}
}
async function importImageURL(url: string): Promise<Asset[]> {
const response = await fetch("/api/assets/import-image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "图片链接导入失败,请检查链接是否有效或稍后重试。");
const asset = payload?.asset;
if (!asset || asset.kind !== "image" || !asset.id || !asset.url || !asset.name) {
throw new Error("图片导入结果不完整,请刷新素材后重试。");
}
return [asset as Asset];
}
async function uploadFiles(files: FileList | File[] | null, options: MaterialUploadOptions = {}) {
if (uploadingRef.current || busy) return;
const allFiles = Array.from(files || []);
const remainingInteractiveSlots = Math.max(0, 10 - seedreamInteractiveSourceKeys.length);
const selectedFiles = options.seedreamSourceMode === "single"
@@ -677,28 +784,69 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。");
return;
}
await receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length);
}
// File paste and URL imports must share the same lock, context checks, source
// selection and reference insertion; a remote URL never goes straight to a model.
async function receiveMaterialAssets(loadAssets: () => Promise<Asset[]>, options: MaterialUploadOptions, requestedCount: number) {
if (uploadingRef.current || busy) return;
uploadingRef.current = true;
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)}素材。`);
const uploaded = await loadAssets();
if (options.paste && options.paste.contextKey !== pasteContextRef.current) {
setNotice("创作模式或编辑目标已切换,本次粘贴未加入当前任务,请重新粘贴。");
return;
}
setMaterials((items) => {
const next = options.replaceAll ? [] : [...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;
if (options.expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== options.expectedType)) {
throw new Error(`请上传${shortTypeName(options.expectedType)}素材。`);
}
if (options.importedURL && options.seedreamSourceMode === "single"
&& uploaded.some((asset) => !["image/png", "image/jpeg"].includes(String(asset.metadata?.contentType)))) {
throw new Error("此图片已导入素材,但图层拆分只支持 PNG 或 JPEG,请使用对应格式的图片。");
}
const next = options.replaceAll ? [] : [...materialsRef.current];
const addedMaterials: PromptMaterial[] = [];
uploaded.forEach((asset, index) => {
const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined;
const material = materialFromAsset(asset, next, targetLabel);
addedMaterials.push(material);
const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1;
if (existingIndex >= 0) {
next[existingIndex] = material;
return;
}
next.push(material);
});
setMaterials(next);
if (options.paste?.insertReferences) {
const pasted = options.paste;
const labels = addedMaterials.map((material) => material.label || "");
const textarea = promptRef.current;
const selection = pasted.selection;
const restoreCaret = textarea && selection && document.activeElement === textarea
&& textarea.value === selection.text
&& textarea.selectionStart === selection.start && textarea.selectionEnd === selection.end;
const expectedPrompt = restoreCaret ? insertClipboardImageReferences(selection.text, labels, selection) : null;
setPromptByMode((items) => ({
...items,
[pasted.mode]: insertClipboardImageReferences(items[pasted.mode], labels, selection)
}));
// Do not steal focus or move a cursor the user changed while uploading.
if (restoreCaret && expectedPrompt !== null && expectedPrompt !== selection.text) {
const cursor = expectedPrompt.length - (selection.text.length - selection.end);
window.requestAnimationFrame(() => {
if (document.activeElement === textarea && textarea.value === expectedPrompt) {
textarea.setSelectionRange(cursor, cursor);
}
});
}
setMentionState(null);
setMaterialDraft(null);
}
if (options.seedreamSourceMode === "single" && uploaded[0]) {
setSeedreamSourceKey(uploaded[0].id || uploaded[0].url);
setSeedreamLayerAnnotations([]);
@@ -707,11 +855,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
const uploadedKeys = uploaded.map((asset) => asset.id || asset.url);
setSeedreamInteractiveSourceKeys((keys) => [...new Set([...keys, ...uploadedKeys])].slice(0, 10));
setSeedreamSourceKey(uploadedKeys[0]);
if (allFiles.length > selectedFiles.length) setNotice(`已添加 ${selectedFiles.length} 张图片,交互编辑最多支持 10 张输入图。`);
if (requestedCount > uploaded.length) setNotice(`已添加 ${uploaded.length} 张图片,交互编辑最多支持 10 张输入图。`);
}
if (options.paste) {
setNotice(requestedCount > uploaded.length
? `当前模式仅接收本次前 ${uploaded.length} 张图片,其余图片未上传。`
: `已${options.importedURL ? "导入" : "粘贴"} ${uploaded.length} 张图片${options.paste.insertReferences ? "并加入提示词引用" : "并选为输入图"}。`);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
uploadingRef.current = false;
setUploading(false);
}
}
@@ -1141,6 +1295,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
}
async function submit() {
if (uploadingRef.current || busy) return;
if (unsupportedSpecializedPlaceholders.length) {
setError(`${imageCreationMode === "layers" ? "图层拆分仅支持 1 张输入图片" : "交互编辑仅支持最多 10 张输入图片,且不接受视频或音频"},请删除 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}。`);
return;
@@ -1351,6 +1506,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
expectedType: "image",
seedreamSourceMode: imageCreationMode === "interactive" ? "append" : "single"
})}
onPasteImage={handleImagePaste}
onInsertAnnotation={insertAtCursor}
/>
) : null}
@@ -1364,6 +1520,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
type="file"
multiple={minimaxVideoMode ? false : !specializedImageMode || imageCreationMode === "interactive"}
accept={minimaxVideoMode ? "image/*" : specializedImageMode ? imageCreationMode === "layers" ? "image/png,image/jpeg" : "image/*" : materialUploadAccept}
disabled={uploading || busy}
onChange={(event) => {
void uploadFiles(event.target.files, minimaxVideoMode
? { expectedType: "image", maxFiles: 1, replaceAll: true }
@@ -1386,6 +1543,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
id="createPrompt"
ref={promptRef}
value={prompt}
onPaste={handleImagePaste}
onChange={(event) => {
handlePromptInput(event.target.value, event.target.selectionStart);
}}
@@ -1426,6 +1584,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
) : null}
</div>
<small>{uploading ? "素材上传或导入中,请稍候…" : "支持 Ctrl+V / ⌘+V 粘贴截图、图片或图片下载地址;链接确认后导入,文字可照常粘贴。"}</small>
{unsupportedSpecializedPlaceholders.length ? (
<div className="specialized-prompt-warning" role="alert">
{imageCreationMode === "layers" ? "图层拆分仅接受 1 张输入图片" : "交互编辑仅接受最多 10 张输入图片,不接受视频或音频"},请从提示词中删除

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { BoxSelect, Brush, Check, Hand, ImageIcon, Loader2, MapPin, Maximize2, Minimize2, Redo2, ScanLine, Trash2, Undo2, Upload, X, ZoomIn, ZoomOut } from "lucide-react";
import clsx from "clsx";
import { materialPreviewUrl } from "@/lib/client/asset-urls";
@@ -37,6 +37,7 @@ export function SeedreamWorkspace({
onSelectSource,
onToggleSource,
onUpload,
onPasteImage,
onInsertAnnotation
}: {
mode: Exclude<SeedreamCreationMode, "basic">;
@@ -49,6 +50,7 @@ export function SeedreamWorkspace({
onSelectSource: (material: PromptMaterial) => void;
onToggleSource: (material: PromptMaterial) => void;
onUpload: (files: FileList | null) => void;
onPasteImage?: (event: ReactClipboardEvent<HTMLElement>) => void;
onInsertAnnotation: (token: string) => void;
}) {
const [tool, setTool] = useState<AnnotationTool>("box");
@@ -324,7 +326,15 @@ export function SeedreamWorkspace({
const currentImageIndex = sourceIndexByKey.get(sourceKey) || 1;
return (
<section ref={workspaceRef} className={clsx("seedream-workspace", mode === "interactive" ? "interactive" : "layers")} aria-label={mode === "interactive" ? "Seedream 交互编辑" : "Seedream 图层拆分"}>
<section
ref={workspaceRef}
className={clsx("seedream-workspace", mode === "interactive" ? "interactive" : "layers")}
aria-label={mode === "interactive" ? "Seedream 交互编辑" : "Seedream 图层拆分"}
onPaste={(event) => {
if (event.target instanceof HTMLElement && event.target.closest("input, textarea, [contenteditable]")) return;
onPasteImage?.(event);
}}
>
<header className="seedream-workspace-head">
<div>
<strong>{mode === "interactive" ? "精准编辑画布" : "指定拆分区域"}</strong>
@@ -334,10 +344,19 @@ export function SeedreamWorkspace({
</header>
{!source ? (
<div className="seedream-source-empty">
<div
className="seedream-source-empty"
tabIndex={0}
aria-label="图片画布:可粘贴截图或复制的图片"
onPointerDown={(event) => {
if (event.target instanceof Element && event.target.closest("label, button, input")) return;
event.currentTarget.focus({ preventScroll: true });
}}
>
<span className="seedream-source-empty-icon" aria-hidden="true"><ImageIcon size={24} /></span>
<strong>{sources.length ? "选择参与生成的图片" : "先上传一张图片"}</strong>
<span>{mode === "interactive" ? "可以选择多张图片进行跨图定位编辑" : "模板效果预览图不会自动参与拆分"}</span>
<span>支持 Ctrl+V / ⌘+V 粘贴图片或图片下载地址,链接确认后导入</span>
<label className="button primary">
{uploading ? <Loader2 className="spin" size={16} /> : <Upload size={16} />}
上传图片
@@ -428,7 +447,7 @@ export function SeedreamWorkspace({
</div>
</div>
<div className="seedream-canvas-note">
<span>滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图</span>
<span>滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图 · Ctrl+V / ⌘+V 粘贴图片或图片地址</span>
{mode === "interactive"
? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。"
: "蓝色涂鸦会合成到待拆图片;框选会转换为 <bbox> 坐标,不会修改原始素材。"}