refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证

This commit is contained in:
ddcat666
2026-10-08 19:38:57 +08:00
parent 9be3aa8235
commit 87ecb3000c
724 changed files with 3046 additions and 3885 deletions

View File

@@ -1,164 +1,50 @@
import { type ReactNode } from "react";
import { useId } from "react";
import { Input, InputNumber, Radio } from "antd";
import { Select } from "@/components/ui/base/select";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { audioFormatOptionsForConfig, audioSpeedLabel, audioVoiceOptionsForConfig, isDoubaoAudioConfig, normalizeAudioFormatForConfig, normalizeAudioSpeedValue, normalizeAudioVoiceForConfig } from "@/lib/audio-generation";
import { type CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig } from "@/stores/use-config-store";
import type { CanvasAudioSettingKey } from "@/components/canvas/canvas-audio-settings-popover";
const speedOptions = ["0.75", "1", "1.25", "1.5"];
type AudioSettingsPanelProps = { config: AiConfig; onConfigChange: (key: CanvasAudioSettingKey, value: string) => void; theme: CanvasTheme; showTitle?: boolean; className?: string };
type AudioSettingKey =
| "audioVoice"
| "audioFormat"
| "audioSpeed"
| "audioLanguage"
| "audioDialect"
| "audioInstructions"
| "audioEmotionControlMethod"
| "audioEmotionRandom"
| "audioEmotionHappy"
| "audioEmotionAngry"
| "audioEmotionSad"
| "audioEmotionAfraid"
| "audioEmotionDisgusted"
| "audioEmotionMelancholic"
| "audioEmotionSurprised"
| "audioEmotionCalm";
type AudioSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: AudioSettingKey, value: string) => void;
theme: CanvasTheme;
showTitle?: boolean;
className?: string;
};
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) {
const isDoubao = isDoubaoAudioConfig(config);
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4" }: AudioSettingsPanelProps) {
const listId = useId();
const streaming = isDoubaoAudioConfig(config);
const voice = normalizeAudioVoiceForConfig(config, config.audioVoice);
const voiceOptions = audioVoiceOptionsForConfig(config);
const visibleVoiceOptions = voiceOptions.some((item) => item.value === voice) ? voiceOptions : [{ value: voice, label: `当前音色(${voice})` }, ...voiceOptions];
const voices = audioVoiceOptionsForConfig(config);
const currentVoice = voices.some((item) => item.value === voice) ? voices : [{ value: voice, label: voice || "不指定音色" }, ...voices];
const formats = audioFormatOptionsForConfig(config);
const format = normalizeAudioFormatForConfig(config, config.audioFormat);
const formatOptions = audioFormatOptionsForConfig(config);
const speed = normalizeAudioSpeedValue(config.audioSpeed);
return (
<ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-lg font-semibold">音频设置</div> : null}
<SettingGroup title="音色" color={theme.node.muted}>
{isDoubao ? (
<div className="space-y-3">
<div className="rounded-xl border px-3 py-2 text-sm leading-6" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
不连接素材时,按提示词直接生成。连接音频时按参考音频生成,最多 3 段。连接图片时按参考图片生成,最多 1 张。图片和音频不能同时连接。
</div>
<select
value={voice}
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text, background: theme.spatial.elevated }}
onChange={(event) => onConfigChange("audioVoice", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
>
<option value="">不指定音色</option>
{voice ? <option value={voice}>当前音色</option> : null}
</select>
</div>
) : (
<>
<input
value={config.audioVoice || ""}
placeholder="输入渠道音色 ID,例如 voice_001"
list="audio-voice-options"
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioVoice", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
<datalist id="audio-voice-options">
{visibleVoiceOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</datalist>
<div className="grid grid-cols-3 gap-2.5">
{visibleVoiceOptions.map((item) => (
<OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</>
)}
</SettingGroup>
<SettingGroup title="格式" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{formatOptions.map((item) => (
<OptionPill key={item.value} selected={format === item.value} theme={theme} onClick={() => onConfigChange("audioFormat", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</SettingGroup>
<SettingGroup title="语速" color={theme.node.muted}>
<div className="grid grid-cols-4 gap-2.5">
{speedOptions.map((value) => (
<OptionPill key={value} selected={speed === value} theme={theme} onClick={() => onConfigChange("audioSpeed", value)}>
{audioSpeedLabel(value)}
</OptionPill>
))}
</div>
<input
type="number"
min={0.25}
max={4}
step={0.05}
className="h-9 w-full rounded-full border bg-transparent px-3 text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text, WebkitTextFillColor: theme.node.text }}
value={config.audioSpeed || "1"}
onChange={(event) => onConfigChange("audioSpeed", event.target.value)}
onBlur={(event) => onConfigChange("audioSpeed", normalizeAudioSpeedValue(event.target.value))}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
{!isDoubao ? (
<SettingGroup title="声音指令" color={theme.node.muted}>
<textarea
value={config.audioInstructions || ""}
placeholder="例如:自然、温暖、适合旁白。"
className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
/>
</SettingGroup>
) : null}
</div>
</ImageSettingsTheme>
);
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClick}
>
{children}
</button>
);
}
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
return (
<div className="space-y-2.5">
<div className="text-xs font-medium" style={{ color }}>
{title}
</div>
{children}
</div>
);
const selectVoice = (value: string) => onConfigChange("audioVoice", value);
return <ImageSettingsTheme theme={theme}>
<section className={className} style={{ color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} aria-label="音频生成参数">
{showTitle ? <h3 className="text-base font-semibold">音频设置</h3> : null}
<fieldset className="space-y-2">
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>音色</legend>
{streaming ? <>
<p className="text-xs leading-5" style={{ color: theme.node.muted }}>无参考素材时按提示词生成;可连接最多 3 段音频或 1 张图片,图片与音频不能混用。</p>
<Select className="w-full" ariaLabel="音色" value={voice} onChange={selectVoice} options={[{ value: "", label: "不指定音色" }, ...(voice ? [{ value: voice, label: `当前音色 · ${voice}` }] : [])]} />
</> : <>
<Input aria-label="渠道音色 ID" value={config.audioVoice || ""} list={listId} placeholder="输入渠道音色 ID" onChange={(event) => selectVoice(event.target.value)} />
<datalist id={listId}>{currentVoice.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</datalist>
<Radio.Group aria-label="常用音色" value={voice} onChange={(event) => selectVoice(event.target.value)} options={currentVoice} className="flex flex-wrap gap-2" />
</>}
</fieldset>
<fieldset>
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>音频格式</legend>
<Radio.Group aria-label="音频格式" value={format} options={formats} onChange={(event) => onConfigChange("audioFormat", event.target.value)} className="flex flex-wrap gap-2" />
</fieldset>
<fieldset className="space-y-2">
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>语速</legend>
<Radio.Group aria-label="常用语速" value={speed} options={["0.75", "1", "1.25", "1.5"].map((value) => ({ value, label: audioSpeedLabel(value) }))} onChange={(event) => onConfigChange("audioSpeed", event.target.value)} />
<InputNumber aria-label="自定义语速" min={0.25} max={4} step={0.05} value={Number(speed)} className="w-full" onChange={(value) => { if (value !== null) onConfigChange("audioSpeed", normalizeAudioSpeedValue(String(value))); }} />
</fieldset>
{!streaming ? <label className="flex flex-col gap-2 text-xs" style={{ color: theme.node.muted }}>声音指令
<Input.TextArea aria-label="声音指令" rows={3} value={config.audioInstructions || ""} placeholder="例如:自然、温暖的旁白" onChange={(event) => onConfigChange("audioInstructions", event.target.value)} />
</label> : null}
</section>
</ImageSettingsTheme>;
}

View File

@@ -14,7 +14,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { ImageAsset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export const CANVAS_IMAGE_ASSET_DND_TYPE = "application/x-infinite-canvas-image-asset";
export const CANVAS_IMAGE_ASSET_DND_TYPE = "application/x-yingce-image-asset";
type TrayTab = "library" | "canvas";

View File

@@ -1,114 +1,17 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { AudioSettingsPanel } from "@/components/audio-settings-panel";
import { audioFormatLabelForConfig, audioSpeedLabel, audioVoiceLabelForConfig } from "@/lib/audio-generation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasSettingsPopover, type CanvasSettingsPlacement } from "./canvas-settings-popover";
export type CanvasAudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioLanguage" | "audioDialect" | "audioInstructions" | "audioEmotionControlMethod" | "audioEmotionRandom" | "audioEmotionHappy" | "audioEmotionAngry" | "audioEmotionSad" | "audioEmotionAfraid" | "audioEmotionDisgusted" | "audioEmotionMelancholic" | "audioEmotionSurprised" | "audioEmotionCalm";
type AudioPopoverProps = { config: AiConfig; onConfigChange: (key: CanvasAudioSettingKey, value: string) => void; buttonClassName?: string; placement?: CanvasSettingsPlacement };
type CanvasAudioSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasAudioSettingsPopoverProps) {
export function CanvasAudioSettingsPopover({ config, onConfigChange, ...popover }: AudioPopoverProps) {
const theme = canvasThemes[useActiveTheme()];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const summary = `${audioVoiceLabelForConfig(config, config.audioVoice) || "不指定音色"} · ${audioFormatLabelForConfig(config, config.audioFormat)} · ${audioSpeedLabel(config.audioSpeed)}`;
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <AudioSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`音频设置:${summary}`} title={`音频设置 · ${summary}`} onClick={() => setOpen((current) => !current)}>
<span className="truncate">{summary}</span>
</Button>
</span>
{panel}
</>
);
}
function AudioSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasAudioSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: "var(--z-dialog-popover)",
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.spatial.elevated,
border: `1px solid ${theme.toolbar.border}`,
borderRadius: 10,
boxShadow: `0 24px 72px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.08)`,
padding: 12,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover aceternity-floating-panel backdrop-blur-2xl"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<AudioSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
</div>,
document.body,
);
const parts = [audioVoiceLabelForConfig(config, config.audioVoice) || "不指定音色", audioFormatLabelForConfig(config, config.audioFormat), audioSpeedLabel(config.audioSpeed)];
return <CanvasSettingsPopover {...popover} label="音频设置" summary={parts.join(" · ")}>
<AudioSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} className="space-y-4" />
</CanvasSettingsPopover>;
}

View File

@@ -0,0 +1,22 @@
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { generationInputMentionLabel, type NodeGenerationInput } from "./canvas-node-generation";
export function composerReferences(inputs: NodeGenerationInput[], skills: CanvasResourceReference[]): CanvasResourceReference[] {
const media = inputs.map((input): CanvasResourceReference => {
const label = generationInputMentionLabel(input, inputs);
return {
id: `composer:${input.nodeId}`, nodeId: input.nodeId, kind: input.type, label,
mentionToken: `@${label}`, title: input.title, active: true, text: input.text,
previewUrl: input.sourceKind === "drawing" ? undefined : input.image?.dataUrl || input.previewUrl,
};
});
return [...skills.map((skill) => ({ ...skill, active: true, mentionToken: `@${skill.label}` })), ...media];
}
export function insertComposerPreset(value: string, start: number, end: number, prompt: string): { text: string; caret: number } {
const from = Math.max(0, Math.min(value.length, start));
const to = Math.max(from, Math.min(value.length, end));
const prefix = value.slice(0, from).replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1");
const insertion = `${prompt} `;
return { text: prefix + insertion + value.slice(to), caret: prefix.length + insertion.length };
}

View File

@@ -1,493 +1,78 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { Button, Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, Video, X } from "lucide-react";
import { Button } from "antd";
import { X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { generationInputMentionLabel, normalizeGenerationNodeMentionTokens, type NodeGenerationInput } from "./canvas-node-generation";
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
import type { CanvasGenerationMode, CanvasNodeMetadata, CanvasWorkspaceMode } from "@/types/canvas";
import { normalizeGenerationNodeMentionTokens, type NodeGenerationInput } from "./canvas-node-generation";
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { getEditableSelection, setEditableSelection } from "./canvas-mention-editable";
import { composerReferences, insertComposerPreset } from "./canvas-composer-references";
type CanvasConfigComposerProps = {
value: string;
inputs: NodeGenerationInput[];
skillReferences?: CanvasResourceReference[];
generationMode?: CanvasGenerationMode;
metadata?: CanvasNodeMetadata;
onChange: (value: string) => void;
onMetadataChange?: (patch: Partial<CanvasNodeMetadata>) => void;
onClose: () => void;
workspaceMode?: CanvasWorkspaceMode;
type ComposerProps = {
value: string; inputs: NodeGenerationInput[]; skillReferences?: CanvasResourceReference[];
generationMode?: CanvasGenerationMode; metadata?: CanvasNodeMetadata;
onChange: (value: string) => void; onMetadataChange?: (patch: Partial<CanvasNodeMetadata>) => void;
onClose: () => void; workspaceMode?: CanvasWorkspaceMode;
};
type Token =
| { type: "text"; value: string }
| { type: "reference"; nodeId: string };
type MentionState = {
query: string;
};
type ComposerCandidate =
| {
kind: "input";
input: NodeGenerationInput;
}
| {
kind: "skill";
reference: CanvasResourceReference;
};
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g;
export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode, metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: CanvasConfigComposerProps) {
export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode = "image", metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: ComposerProps) {
const theme = canvasThemes[useActiveTheme()];
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [presetOpen, setPresetOpen] = useState(false);
const simpleMode = workspaceMode === "simple";
const workflowVideoReferenceMode = generationMode === "video" && isCanvasWorkflowProvider(metadata);
const normalizedValue = useMemo(() => normalizeGenerationNodeMentionTokens(value, inputs), [inputs, value]);
const tokens = useMemo(() => parseComposerTokens(normalizedValue, inputs), [inputs, normalizedValue]);
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
const videoFrameOptions = useMemo(
() =>
inputs
.filter((input) => input.type === "image" && input.image)
.map((input) => ({ nodeId: input.nodeId, label: resourceLabel(input, inputs), title: input.title, previewUrl: input.image?.dataUrl })),
[inputs],
);
const candidates = useMemo(() => {
if (!mention) return [];
const query = (mention.query || "").trim().toLowerCase();
const items: ComposerCandidate[] = [...skillReferences.map((reference) => ({ kind: "skill" as const, reference })), ...inputs.map((input) => ({ kind: "input" as const, input }))];
if (!query) return items;
return items.filter((item) => candidateSearchText(item, inputs).toLowerCase().includes(query));
}, [inputs, mention, skillReferences]);
const container = useRef<HTMLDivElement>(null);
const caret = useRef<{ start: number; end: number } | null>(null);
const [presetsOpen, setPresetsOpen] = useState(false);
const references = useMemo(() => composerReferences(inputs, skillReferences), [inputs, skillReferences]);
const prompt = useMemo(() => normalizeGenerationNodeMentionTokens(value, inputs), [value, inputs]);
const simple = workspaceMode === "simple";
const workflow = generationMode === "video" && isCanvasWorkflowProvider(metadata);
useEffect(() => { if (prompt !== value) onChange(prompt); }, [prompt, value, onChange]);
useEffect(() => {
if (normalizedValue !== value) {
onChange(normalizedValue);
return;
}
if (document.activeElement === editorRef.current) return;
const editor = editorRef.current;
if (!editor) return;
editor.textContent = "";
tokens.forEach((token) => {
if (token.type === "text") {
editor.append(document.createTextNode(token.value));
return;
}
const input = referenceById.get(token.nodeId);
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
});
}, [inputs, normalizedValue, onChange, referenceById, theme, tokens, value]);
const syncFromEditor = () => {
const editor = editorRef.current;
if (!editor) return;
const next = serializeEditor(editor);
onChange(next);
syncMention();
const rememberCaret = () => {
const editor = container.current?.querySelector<HTMLElement>("[contenteditable='true'],textarea");
caret.current = editor instanceof HTMLTextAreaElement ? { start: editor.selectionStart, end: editor.selectionEnd } : getEditableSelection(editor || null);
};
const syncMention = () => {
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match || (!inputs.length && !skillReferences.length)) {
closeMention();
return;
}
const nextQuery = match[1] || "";
if (mention?.query !== nextQuery) {
setMention({ query: nextQuery });
setActiveIndex(0);
}
};
const closeMention = () => {
setMention(null);
setActiveIndex(0);
};
const insertCandidate = (candidate: ComposerCandidate) => {
const editor = editorRef.current;
if (!editor) return;
removeActiveMention();
const space = document.createTextNode(" ");
const node = candidate.kind === "skill" ? document.createTextNode(`@${candidate.reference.label}`) : createReferenceChip(candidate.input, inputs, theme, setImagePreview);
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (range) {
range.insertNode(space);
range.insertNode(node);
range.setStartAfter(space);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
editor.append(node, space);
placeCaretAtEnd(editor);
}
closeMention();
onChange(serializeEditor(editor));
};
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
const insertPreset = (preset: CanvasPromptPreset) => {
const editor = editorRef.current;
if (!editor) return;
removeActiveSlash(editor);
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
const text = document.createTextNode(`${preset.prompt} `);
if (range && editor.contains(range.commonAncestorContainer)) {
range.insertNode(text);
range.setStartAfter(text);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
editor.append(text);
placeCaretAtEnd(editor);
}
onChange(serializeEditor(editor));
const position = caret.current || { start: prompt.length, end: prompt.length };
const next = insertComposerPreset(prompt, position.start, position.end, preset.prompt);
onChange(next.text);
setPresetsOpen(false);
requestAnimationFrame(() => {
const editor = container.current?.querySelector<HTMLElement>("[contenteditable='true'],textarea");
editor?.focus();
if (editor instanceof HTMLTextAreaElement) editor.setSelectionRange(next.caret, next.caret);
else if (editor) setEditableSelection(editor, next.caret);
});
};
const frameOptions = references.filter((ref) => ref.kind === "image" && ref.previewUrl).map((ref) => ({ nodeId: ref.nodeId, label: ref.label, title: ref.title, previewUrl: ref.previewUrl }));
return (
<div
data-canvas-no-zoom
className="canvas-config-composer aceternity-floating-panel rounded-xl p-4 backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, color: theme.node.text }}
onMouseDown={stopCanvasInteraction}
onPointerDown={stopCanvasInteraction}
onWheel={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold">{simpleMode ? "快速生成" : "组装提示词"}</div>
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : workflowVideoReferenceMode ? "已连接媒体会按工作流字段顺序自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
<section data-canvas-no-zoom className="canvas-config-composer rounded-xl bg-surface-active p-4" style={{ background: theme.spatial.elevated, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<header className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-xs font-semibold">{simple ? "快速生成" : "组装提示词"}</h3>
<p className="mt-1 text-xs opacity-60">{workflow ? "工作流按字段顺序接收连接素材" : "输入 @ 选择已连接素材或技能"}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{simpleMode ? null : <CanvasPresetPicker mode={generationMode || "image"} skillReferences={skillReferences} open={presetOpen} onOpenChange={setPresetOpen} onSelect={insertPreset} />}
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
{!simple ? <CanvasPresetPicker mode={generationMode} skillReferences={skillReferences} open={presetsOpen} onOpenChange={setPresetsOpen} onSelect={insertPreset} /> : null}
<Button size="small" type="text" aria-label="关闭提示词编辑器" icon={<X className="size-4" />} onClick={onClose} />
</div>
</div>
{generationMode === "video" && onMetadataChange && !simpleMode ? (
<div className="mb-3 rounded-lg px-2 py-2" style={{ background: theme.node.fill }}>
<CanvasVideoPromptTools
metadata={metadata}
frameOptions={videoFrameOptions}
referenceMode={workflowVideoReferenceMode ? "all" : "frames"}
referenceSummary={{
imageCount: inputs.filter((input) => input.type === "image" || input.type === "character").length,
videoCount: inputs.filter((input) => input.type === "video").length,
audioCount: inputs.filter((input) => input.type === "audio").length,
}}
onMetadataChange={onMetadataChange}
/>
</div>
) : null}
<div className="canvas-config-composer-editor relative rounded-lg" style={{ background: theme.node.fill }}>
{!value.trim() ? <div className="pointer-events-none absolute left-4 top-3 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接素材或技能</div> : null}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="thin-scrollbar min-h-32 max-h-[min(42vh,360px)] w-full overflow-y-auto whitespace-pre-wrap break-words px-4 py-3 text-sm leading-7 outline-none"
style={{ color: theme.node.text }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
syncFromEditor();
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
</header>
{generationMode === "video" && onMetadataChange && !simple ? <div className="mb-3">
<CanvasVideoPromptTools metadata={metadata} frameOptions={frameOptions} referenceMode={workflow ? "all" : "frames"}
referenceSummary={{ imageCount: inputs.filter((input) => input.type === "image" || input.type === "character").length, videoCount: inputs.filter((input) => input.type === "video").length, audioCount: inputs.filter((input) => input.type === "audio").length }} onMetadataChange={onMetadataChange} />
</div> : null}
<div ref={container} className="canvas-config-composer-editor rounded-lg" style={{ background: theme.node.fill }} onBlurCapture={rememberCaret} onKeyUpCapture={rememberCaret} onPointerUpCapture={rememberCaret}>
<CanvasResourceMentionTextarea aria-label="组装生成提示词" value={prompt} references={references} onChange={onChange} sendOnEnter={false}
placeholder="输入提示词,按 @ 引用连接素材或技能" className="min-h-32 max-h-[min(42vh,360px)] w-full px-4 py-3 text-sm leading-7"
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "/" && !mention) window.setTimeout(() => setPresetOpen(true));
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
insertCandidate(candidates[Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
event.preventDefault();
requestAnimationFrame(syncFromEditor);
return;
}
requestAnimationFrame(syncMention);
}}
onBlur={() => window.setTimeout(closeMention, 120)}
/>
{mention && candidates.length ? <MentionMenu candidates={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertCandidate} /> : null}
if (event.key === "/" && !simple && !event.nativeEvent.isComposing) requestAnimationFrame(() => { rememberCaret(); setPresetsOpen(true); });
}} />
</div>
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div>
);
}
function removeActiveSlash(editor: HTMLDivElement) {
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (!range || !editor.contains(range.commonAncestorContainer)) return;
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const value = node.textContent || "";
const before = value.slice(0, range.startOffset);
const match = /(^|\s)\/[\p{L}\p{N}_-]*$/u.exec(before);
if (!match) return;
node.textContent = `${before.slice(0, match.index)}${match[1]}${value.slice(range.startOffset)}`;
range.setStart(node, match.index + match[1].length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
function MentionMenu({ candidates, allInputs, activeIndex, theme, onSelect }: { candidates: ComposerCandidate[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (candidate: ComposerCandidate) => void }) {
const selectedRef = useRef(false);
const activeItemRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
activeItemRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex, candidates]);
const selectInput = (candidate: ComposerCandidate) => {
if (selectedRef.current) return;
selectedRef.current = true;
onSelect(candidate);
};
return (
<div className="aceternity-floating-panel absolute left-2 top-[calc(100%+6px)] z-[var(--z-modal)] max-h-56 w-64 overflow-y-auto rounded-lg border p-1" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border }}>
{candidates.map((candidate, index) => (
<button
key={candidate.kind === "skill" ? candidate.reference.id : candidate.input.nodeId}
ref={index === activeIndex ? activeItemRef : undefined}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition"
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
selectInput(candidate);
}}
>
<ResourcePreview candidate={candidate} />
<span className="min-w-0 flex-1">
<span className="block font-medium">{candidate.kind === "skill" ? candidate.reference.label : resourceLabel(candidate.input, allInputs)}</span>
{candidate.kind !== "skill" ? <span className="block truncate opacity-65">{candidate.input.text || candidate.input.title}</span> : null}
</span>
</button>
))}
</div>
</section>
);
}
function ResourcePreview({ candidate }: { candidate: ComposerCandidate }) {
if (candidate.kind === "skill") {
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-cyan-500/12 text-cyan-600 dark:text-cyan-200">
<Sparkles className="size-4" />
</span>
);
}
const input = candidate.input;
if (input.sourceKind === "drawing") {
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
<Pencil className="size-4" />
</span>
);
}
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
if (input.type === "video" && input.previewUrl) return <img src={input.previewUrl} alt="" className="size-9 rounded-md bg-black object-cover" loading="lazy" decoding="async" />;
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
<Icon className="size-4" />
</span>
);
}
function candidateSearchText(candidate: ComposerCandidate, inputs: NodeGenerationInput[]) {
if (candidate.kind === "skill") return `技能 ${candidate.reference.label} ${candidate.reference.title} ${candidate.reference.text || ""}`;
return `${resourceLabel(candidate.input, inputs)} ${candidate.input.title} ${candidate.input.text || ""}`;
}
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
const wrapper = document.createElement("span");
wrapper.contentEditable = "false";
wrapper.dataset.referenceNodeId = input.nodeId;
wrapper.dataset.referenceToken = `@${generationInputMentionLabel(input, inputs)}`;
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
Object.assign(wrapper.style, chipStyle(theme));
const previewUrl = input.type === "image" && input.image && input.sourceKind !== "drawing" ? input.image.dataUrl : input.type === "video" ? input.previewUrl : "";
if (previewUrl) {
const image = document.createElement("img");
image.src = previewUrl;
image.alt = input.title;
image.className = "size-6 rounded object-cover";
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
wrapper.appendChild(image);
wrapper.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onImagePreview(previewUrl);
});
} else {
wrapper.title = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.text || input.title;
const text = document.createElement("span");
text.className = "block truncate";
text.textContent = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.type === "text" ? input.text || input.title : input.title;
wrapper.appendChild(text);
}
return wrapper;
}
function serializeEditor(editor: HTMLElement) {
return serializeNodes(editor.childNodes).replace(/\uFEFF/g, "");
}
function serializeNodes(nodes: NodeListOf<ChildNode>) {
let result = "";
nodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
if (!(node instanceof HTMLElement)) return;
const referenceToken = node.dataset.referenceToken;
if (referenceToken) result += referenceToken;
else if (node.tagName === "BR") result += "\n";
else result += serializeNodes(node.childNodes);
});
return result;
}
function removeActiveMention() {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match) return;
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
range.deleteContents();
}
function deleteAdjacentReference(key: string) {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const target = adjacentReferenceNode(range, key);
if (!target) return false;
const nextCaretNode = document.createTextNode("");
target.replaceWith(nextCaretNode);
range.setStart(nextCaretNode, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function adjacentReferenceNode(range: Range, key: string) {
const container = range.startContainer;
const offset = range.startOffset;
const previous = key === "Backspace";
if (container.nodeType === Node.TEXT_NODE) {
const text = container.textContent || "";
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
return findReferenceSibling(container, previous);
}
const children = Array.from(container.childNodes);
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
}
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
return current instanceof HTMLElement && current.dataset.referenceNodeId ? current : null;
}
function textBeforeCaret() {
const selection = window.getSelection();
if (!selection?.rangeCount) return "";
const range = selection.getRangeAt(0).cloneRange();
const editor = closestEditor(range.startContainer);
if (!editor) return "";
range.setStart(editor, 0);
return range.toString();
}
function closestEditor(node: Node) {
const element = node instanceof Element ? node : node.parentElement;
return element?.closest("[contenteditable='true']") || null;
}
function placeCaretAtEnd(element: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function parseComposerTokens(value: string, inputs: NodeGenerationInput[]): Token[] {
const tokens: Token[] = [];
const inputByLabel = new Map(inputs.map((input) => [generationInputMentionLabel(input, inputs), input]));
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
let lastIndex = 0;
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
if (match.index === undefined) continue;
const input = match[1] ? inputByNodeId.get(match[1]) : inputByLabel.get(`${match[2]}${match[3]}`);
const end = match.index + match[0].length;
if (!input || (!match[1] && !hasMentionBoundary(value, end))) continue;
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
tokens.push({ type: "reference", nodeId: input.nodeId });
lastIndex = end;
}
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
return tokens;
}
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
return generationInputMentionLabel(input, inputs);
}
function hasMentionBoundary(value: string, index: number) {
const char = value[index];
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
}
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
}

View File

@@ -1,13 +1,9 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasSettingsPopover, type CanvasSettingsPlacement } from "./canvas-settings-popover";
type CanvasImageSettingsPopoverProps = {
config: AiConfig;
@@ -16,121 +12,23 @@ type CanvasImageSettingsPopoverProps = {
onOpenChange?: (open: boolean) => void;
buttonClassName?: string;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
placement?: CanvasSettingsPlacement;
autoAdjustOverflow?: boolean;
showCount?: boolean;
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: CanvasImageSettingsPopoverProps) {
export function CanvasImageSettingsPopover({ config, onConfigChange, showCount = true, onMissingConfig: _unused, ...popover }: CanvasImageSettingsPopoverProps) {
const theme = canvasThemes[useActiveTheme()];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
const normalized = normalizeImageValue(profile, config);
const summaryParts = [
...(profile.size.parameter !== "none" ? [imageSizeLabel(normalized.size)] : []),
...(profile.quality.supported ? [imageQualityLabel(normalized.quality)] : []),
...(showCount && profile.maxOutputs > 1 ? [`${normalized.count} 张`] : []),
...(profile.transparentBackground.supported && normalized.transparentBackground === "true" ? ["透明"] : []),
];
const summary = summaryParts.join(" · ");
const hasSettings = profile.size.parameter !== "none" || profile.quality.supported || profile.transparentBackground.supported || (showCount && profile.maxOutputs > 1);
const updateOpen = (nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
};
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
if (document.activeElement instanceof HTMLElement && panelRef.current?.contains(document.activeElement)) document.activeElement.blur();
setOpen(false);
onOpenChange?.(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [onOpenChange, open]);
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} showCount={showCount} onConfigChange={onConfigChange} /> : null;
if (!hasSettings) return null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
<span className="truncate">{summary}</span>
</Button>
</span>
{panel}
</>
);
}
function ImageSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
showCount,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasImageSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
showCount: boolean;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const gap = 8;
const margin = 12;
const width = Math.min(420, window.innerWidth - margin * 2);
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: "var(--z-dialog-popover)",
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.canvas.background,
border: `1px solid ${theme.toolbar.border}`,
borderRadius: 10,
boxShadow: `0 24px 72px ${theme.spatial.shadow}`,
padding: 12,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover aceternity-floating-panel backdrop-blur-2xl"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
</div>,
document.body,
);
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image;
if (!profile) return null;
const current = normalizeImageValue(profile, config);
const parts: string[] = [];
if (profile.size.parameter !== "none") parts.push(imageSizeLabel(current.size));
if (profile.quality.supported) parts.push(imageQualityLabel(current.quality));
if (showCount && profile.maxOutputs > 1) parts.push(`${current.count} 张`);
if (profile.transparentBackground.supported && current.transparentBackground === "true") parts.push("透明");
if (!parts.length && !profile.transparentBackground.supported) return null;
return <CanvasSettingsPopover {...popover} label="图像设置" summary={parts.join(" · ") || "背景"} width={420}>
<ImageSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
</CanvasSettingsPopover>;
}

View File

@@ -1,271 +1,103 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal } from "antd";
import { Check, Lock, LockOpen, X } from "lucide-react";
import { useRef, useState, type PointerEvent } from "react";
import { Button } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { resolveCropPixels, type ImageCropRect } from "@/lib/canvas/image-operation-plan";
import { resizeSelection, selectAspect, translateSelection, type CropGrip } from "@/lib/canvas/image-crop-selection";
import { useImageToolSource } from "./use-image-tool-source";
import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageCropRect = {
x: number;
y: number;
width: number;
height: number;
};
type DragMode = "move" | "resize";
type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
type CropAspectPreset = "original" | "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "16:9" | "9:16";
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
const cropAspectPresets: Array<{ value: CropAspectPreset; label: string; ratio?: number }> = [
{ value: "original", label: "原图" },
{ value: "1:1", label: "1:1", ratio: 1 },
{ value: "3:2", label: "3:2", ratio: 3 / 2 },
{ value: "2:3", label: "2:3", ratio: 2 / 3 },
{ value: "4:3", label: "4:3", ratio: 4 / 3 },
{ value: "3:4", label: "3:4", ratio: 3 / 4 },
{ value: "16:9", label: "16:9", ratio: 16 / 9 },
{ value: "9:16", label: "9:16", ratio: 9 / 16 },
export type CanvasImageCropRect = ImageCropRect;
type CropProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void };
const initialSelection: ImageCropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
const grips: { name: string; vector: CropGrip }[] = [
{ name: "nw", vector: [-1, -1] }, { name: "n", vector: [0, -1] }, { name: "ne", vector: [1, -1] },
{ name: "w", vector: [-1, 0] }, { name: "e", vector: [1, 0] },
{ name: "sw", vector: [-1, 1] }, { name: "s", vector: [0, 1] }, { name: "se", vector: [1, 1] },
];
type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null };
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [lockedRatio, setLockedRatio] = useState<number | null>(null);
const [activePreset, setActivePreset] = useState<CropAspectPreset | "custom" | null>(null);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
export function CanvasNodeCropDialog(props: CropProps) {
return props.open && props.dataUrl ? <CropEditor key={props.dataUrl} {...props} /> : null;
}
useEffect(() => {
if (!open) return;
setCrop(defaultCrop);
setLockedRatio(null);
setActivePreset(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setImage(null);
void readImageMeta(dataUrl).then((meta) => {
if (!cancelled) setImage(meta);
});
return () => {
cancelled = true;
};
}, [dataUrl, open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect();
if (!box) return;
event.preventDefault();
event.stopPropagation();
const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", lockedRatio, box));
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
function CropEditor({ dataUrl, onClose, onConfirm }: CropProps) {
const { size, error } = useImageToolSource(dataUrl);
const [selection, setSelection] = useState(initialSelection);
const [locked, setLocked] = useState(false);
const [preset, setPreset] = useState("");
const stage = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const output = size ? resolveCropPixels(size, selection) : null;
const begin = (event: PointerEvent<HTMLElement>, grip: CropGrip | null) => {
if (event.button !== 0 || gesture.current || !stage.current || !size) return;
const bounds = stage.current.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip };
};
const selectAspectPreset = (preset: (typeof cropAspectPresets)[number]) => {
if (!image) return;
const ratio = preset.ratio || image.width / image.height;
setCrop((current) => fitCropToAspect(current, ratio, image));
setLockedRatio(ratio);
setActivePreset(preset.value);
const finish = (event: PointerEvent<HTMLElement>) => {
if (event.pointerId !== gesture.current?.pointer) return;
gesture.current = null;
if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) event.target.releasePointerCapture(event.pointerId);
};
const toggleAspectLock = () => {
if (lockedRatio !== null) {
setLockedRatio(null);
setActivePreset(null);
return;
}
if (!image) return;
setLockedRatio((crop.width * image.width) / (crop.height * image.height));
setActivePreset("custom");
const choose = (value: string) => {
if (!size) return;
const ratio = value === "原图" ? 1 : (() => { const [w, h] = value.split(":").map(Number); return (w * size.height) / (h * size.width); })();
setSelection((box) => selectAspect(box, ratio)); setPreset(value); setLocked(true);
};
const resetCrop = () => {
setCrop(defaultCrop);
setLockedRatio(null);
setActivePreset(null);
};
return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<AppModal open centered width={780} title="裁剪图片" onCancel={onClose} onOk={() => size && onConfirm(selection)} okText="确认裁剪" okButtonProps={{ disabled: !size }}>
<div className="space-y-4">
<div className="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
))}
</div>
<div ref={stage} className="relative inline-block max-w-full touch-none select-none overflow-hidden rounded-lg"
onPointerMove={(event) => {
const drag = gesture.current;
if (!drag || drag.pointer !== event.pointerId) return;
const dx = (event.clientX - drag.origin[0]) / drag.display[0];
const dy = (event.clientY - drag.origin[1]) / drag.display[1];
setSelection(drag.grip ? resizeSelection(drag.selection, drag.grip, dx, dy, locked) : translateSelection(drag.selection, dx, dy));
}} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={finish}>
<img src={dataUrl} alt="待裁剪图片" draggable={false} className="block max-h-[60vh] max-w-full" />
{size ? <div role="group" aria-label="裁剪区域" tabIndex={0} className="absolute cursor-move border border-white outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ left: `${selection.x * 100}%`, top: `${selection.y * 100}%`, width: `${selection.width * 100}%`, height: `${selection.height * 100}%`, boxShadow: "0 0 0 100vmax rgb(0 0 0 / 55%)" }}
onPointerDown={(event) => begin(event, null)}
onKeyDown={(event) => {
const vectors: Record<string, readonly [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const direction = vectors[event.key];
if (!direction) return;
event.preventDefault(); event.stopPropagation();
const step = event.shiftKey ? 10 : 1;
setSelection((box) => translateSelection(box, (direction[0] * step) / size.width, (direction[1] * step) / size.height));
}}>
<svg viewBox="0 0 3 3" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<path d="M1 0V3M2 0V3M0 1H3M0 2H3" stroke="white" strokeOpacity={0.5} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</svg>
{grips.map(({ name, vector }) => <button type="button" key={name} aria-label={`调整裁剪框 ${name}`} className="absolute size-3 rounded-full border border-black bg-white"
style={{ left: `${(vector[0] + 1) * 50}%`, top: `${(vector[1] + 1) * 50}%`, transform: "translate(-50%, -50%)", cursor: `${name}-resize` }}
onPointerDown={(event) => begin(event, vector)}
onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault(); event.stopPropagation();
const step = event.shiftKey ? 10 : 1;
const dx = (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0) / size.width;
const dy = (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0) / size.height;
setSelection((box) => resizeSelection(box, vector, dx, dy, locked));
}} />)}
</div> : null}
</div>
</div>
<div>
<div className="mb-2 text-sm font-medium">常用比例</div>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-8">
{cropAspectPresets.map((preset) => (
<Button key={preset.value} size="small" type={activePreset === preset.value ? "primary" : "default"} disabled={!image} aria-pressed={activePreset === preset.value} onClick={() => selectAspectPreset(preset)}>
{preset.label}
</Button>
))}
<div className="flex flex-wrap gap-2" role="group" aria-label="常用裁剪比例">
{["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => <Button key={value} size="small" disabled={!size} aria-pressed={preset === value} onClick={() => choose(value)}>{value}</Button>)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<output>{output ? `输出 ${output.width} × ${output.height} px` : error || "正在读取图片…"}</output>
<div className="flex gap-2">
<Button disabled={!size} aria-pressed={locked} onClick={() => { setLocked(!locked); setPreset(""); }}>{locked ? "解除比例锁定" : "锁定当前比例"}</Button>
<Button onClick={() => { setSelection(initialSelection); setLocked(false); setPreset(""); }}>重置</Button>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span>比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? (
<span>
原图 {image.width} x {image.height}
</span>
) : null}
</div>
<Button disabled={!image} icon={lockedRatio !== null ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={toggleAspectLock}>
{lockedRatio !== null ? "解除比例锁定" : "锁定当前比例"}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={resetCrop}>重置</Button>
<Button icon={<X className="size-4" />} onClick={onClose}>
取消
</Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
确认裁剪
</Button>
</div>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</div>
</Modal>
</AppModal>
);
}
function CropMask({ crop }: { crop: CanvasImageCropRect }) {
return (
<>
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
</>
);
}
function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect {
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
}
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, lockedRatio: number | null, box: DOMRect): CanvasImageCropRect {
if (lockedRatio !== null) return resizeLockedCrop(crop, dx, dy, handle, lockedRatio, box);
let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy;
if (handle.includes("w")) {
next.x = crop.x + dx;
next.width = crop.width - dx;
}
if (handle.includes("n")) {
next.y = crop.y + dy;
next.height = crop.height - dy;
}
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height);
return next;
}
function resizeLockedCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, ratio: number, box: DOMRect): CanvasImageCropRect {
// 裁剪坐标按显示框归一化,像素宽高比需先换算到归一化坐标系。
const normalizedRatio = (ratio * box.height) / box.width;
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return crop;
const horizontal = handle.includes("e") || handle.includes("w");
const vertical = handle.includes("n") || handle.includes("s");
const candidateWidth = handle.includes("w") ? crop.width - dx : handle.includes("e") ? crop.width + dx : crop.width;
const candidateHeight = handle.includes("n") ? crop.height - dy : handle.includes("s") ? crop.height + dy : crop.height;
let width = candidateWidth;
if (!horizontal || (vertical && Math.abs((candidateHeight - crop.height) * box.height) > Math.abs((candidateWidth - crop.width) * box.width))) {
width = candidateHeight * normalizedRatio;
}
// 角点固定对角,单边固定对边并以另一轴中心为锚点,避免锁定比例时裁剪框漂移。
const anchorX = handle.includes("w") ? crop.x + crop.width : handle.includes("e") ? crop.x : crop.x + crop.width / 2;
const anchorY = handle.includes("n") ? crop.y + crop.height : handle.includes("s") ? crop.y : crop.y + crop.height / 2;
const maxWidthByX = handle.includes("w") ? anchorX : handle.includes("e") ? 1 - anchorX : 2 * Math.min(anchorX, 1 - anchorX);
const maxHeightByY = handle.includes("n") ? anchorY : handle.includes("s") ? 1 - anchorY : 2 * Math.min(anchorY, 1 - anchorY);
const maxWidth = Math.min(maxWidthByX, maxHeightByY * normalizedRatio);
if (maxWidth <= 0) return crop;
const minWidth = Math.min(maxWidth, Math.max(minSize, minSize * normalizedRatio));
width = clamp(width, minWidth, maxWidth);
const height = width / normalizedRatio;
const x = handle.includes("w") ? anchorX - width : handle.includes("e") ? anchorX : anchorX - width / 2;
const y = handle.includes("n") ? anchorY - height : handle.includes("s") ? anchorY : anchorY - height / 2;
return { x: clamp(x, 0, 1 - width), y: clamp(y, 0, 1 - height), width, height };
}
function fitCropToAspect(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect {
const normalizedRatio = (ratio * image.height) / image.width;
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return crop;
// 切换快捷比例时尽量保持当前裁剪面积和中心位置,超出边界时再整体缩小。
const area = crop.width * crop.height;
let width = Math.sqrt(area * normalizedRatio);
let height = width / normalizedRatio;
const minimumScale = Math.max(1, minSize / width, minSize / height);
width *= minimumScale;
height *= minimumScale;
const maximumScale = Math.min(1, 1 / width, 1 / height);
width *= maximumScale;
height *= maximumScale;
const centerX = crop.x + crop.width / 2;
const centerY = crop.y + crop.height / 2;
return {
x: clamp(centerX - width / 2, 0, 1 - width),
y: clamp(centerY - height / 2, 0, 1 - height),
width,
height,
};
}
function cropStyle(crop: CanvasImageCropRect) {
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
}
function handleStyle(handle: ResizeHandle) {
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
return { top, left, cursor: `${handle}-resize` };
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function formatRatio(width: number, height: number) {
const divisor = gcd(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
}
function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : Math.max(1, a);
}

View File

@@ -1,327 +1,107 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Slider } from "antd";
import { Brush, ChevronDown, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { Button, Collapse, Input, Segmented, Slider } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import type { AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { exportEditMask, paintMaskStrokes, type MaskPoint, type MaskStroke } from "@/lib/canvas/image-mask-document";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
import { useImageToolSource } from "./use-image-tool-source";
export type CanvasImageMaskEditPayload = {
prompt: string;
maskDataUrl: string;
prompt: string;
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality" | "count" | "transparentBackground">>;
};
type MaskEditorProps = { dataUrl: string; open: boolean; config: AiConfig; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void };
type DrawMode = "paint" | "erase";
export function CanvasNodeMaskEditDialog(props: MaskEditorProps) {
return props.open && props.dataUrl ? <MaskEditor key={props.dataUrl} {...props} /> : null;
}
const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)";
const maskBorderColor = "rgba(255, 255, 255, .72)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onConfirm }: { dataUrl: string; open: boolean; config: AiConfig; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
function MaskEditor({ dataUrl, config, onClose, onConfirm }: MaskEditorProps) {
const { size, error: loadError } = useImageToolSource(dataUrl);
const surface = useRef<HTMLCanvasElement>(null);
const strokes = useRef<MaskStroke[]>([]);
const activePointer = useRef<number | null>(null);
const frame = useRef<number | null>(null);
const [diameter, setDiameter] = useState(100);
const [mode, setMode] = useState("paint");
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(defaultBrushSize);
const [mode, setMode] = useState<DrawMode>("paint");
const [settings, setSettings] = useState(config);
const [error, setError] = useState("");
const [generationConfig, setGenerationConfig] = useState<AiConfig>(() => config);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [strokeCount, setStrokeCount] = useState(0);
const theme = canvasThemes[useActiveTheme()];
useEffect(() => {
if (!open) return;
setPrompt("");
setBrushSize(defaultBrushSize);
setMode("paint");
setError("");
setAdvancedOpen(true);
setGenerationConfig(config);
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current);
}, [image]);
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
const maskCanvas = maskCanvasRef.current;
const context = maskCanvas?.getContext("2d");
if (!maskCanvas || !context) return;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = brushSize;
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = "#000";
context.fillStyle = "#000";
if (!drawingRef.current.last) {
drawMaskStroke(context, point, point, brushSize);
} else {
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
}
renderMaskPreview(maskCanvas, previewCanvasRef.current);
drawingRef.current.last = point;
if (mode === "paint") {
setError("");
}
useEffect(() => () => { if (frame.current !== null) cancelAnimationFrame(frame.current); }, []);
const repaint = () => {
if (frame.current !== null) return;
frame.current = requestAnimationFrame(() => {
frame.current = null;
if (!surface.current) return;
try { paintMaskStrokes(surface.current, strokes.current, "#2563eb"); }
catch (failure) { setError(failure instanceof Error ? failure.message : "遮罩预览失败"); }
});
};
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
drawingRef.current = { active: true, last: null };
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
draw(event);
const pointAt = (event: PointerEvent<HTMLCanvasElement>): MaskPoint => {
const rect = event.currentTarget.getBoundingClientRect();
return [((event.clientX - rect.left) * event.currentTarget.width) / Math.max(1, rect.width), ((event.clientY - rect.top) * event.currentTarget.height) / Math.max(1, rect.height)];
};
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current.active) return;
event.preventDefault();
draw(event);
const finishStroke = (event: PointerEvent<HTMLCanvasElement>) => {
if (activePointer.current !== event.pointerId) return;
activePointer.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
setStrokeCount(strokes.current.length);
};
const stopDraw = () => {
drawingRef.current = { active: false, last: null };
const maskCanvas = maskCanvasRef.current;
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
};
const resetMask = () => {
clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current);
setError("");
};
const submit = () => {
const nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current;
if (!nextPrompt) return setError("请输入修改要求");
if (!canvas) return;
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas), generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality, count: generationConfig.count, transparentBackground: generationConfig.transparentBackground } });
if (!prompt.trim()) return setError("请填写修改要求");
if (!size) return;
let maskDataUrl: string;
try { maskDataUrl = exportEditMask(size.width, size.height, strokes.current); }
catch (failure) { setError(failure instanceof Error ? failure.message : "遮罩生成失败"); return; }
const { model, imageModel, quality, count, transparentBackground } = settings;
onConfirm({ prompt: prompt.trim(), maskDataUrl, generationConfig: { model, imageModel, size: settings.size, quality, count, transparentBackground } });
};
return (
<Modal className="workspace-modal workspace-modal-wide" title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden>
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_340px]">
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="relative z-0 block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 z-10 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
/>
</>
) : null}
<AppModal open centered title="局部重绘" className="workspace-modal workspace-modal-wide" onCancel={onClose} onOk={submit} okText="AI 修改" okButtonProps={{ disabled: !size }}>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<section className="flex min-w-0 flex-col items-center justify-center gap-3 rounded-lg bg-surface-active p-3">
<div className="relative max-w-full select-none overflow-hidden rounded-lg">
<img src={dataUrl} alt="局部重绘原图" draggable={false} className="block max-h-[62vh] max-w-full" />
{size ? <canvas ref={surface} width={size.width} height={size.height} aria-label="涂抹需要修改的图片区域" className="absolute inset-0 h-full w-full touch-none cursor-crosshair opacity-40"
onPointerDown={(event) => {
if (event.button !== 0 || activePointer.current !== null) return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
activePointer.current = event.pointerId;
strokes.current.push({ diameter, erase: mode === "erase", points: [pointAt(event)] });
setError(""); repaint();
}}
onPointerMove={(event) => {
if (activePointer.current !== event.pointerId) return;
event.preventDefault();
strokes.current.at(-1)?.points.push(pointAt(event)); repaint();
}} onPointerUp={finishStroke} onPointerCancel={finishStroke} onLostPointerCapture={finishStroke} /> : null}
</div>
</div>
<div className="flex max-h-[68vh] min-h-[360px] flex-col overflow-hidden">
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<h2 className="text-xl font-semibold">局部重绘</h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
画笔
</Button>
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
擦除
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75">笔刷大小</span>
<span className="font-semibold">{brushSize}px</span>
</div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">修改要求</div>
<Input.TextArea
rows={4}
value={prompt}
status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
onChange={(event) => {
setPrompt(event.target.value);
setError("");
}}
/>
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
</div>
<div className="rounded-xl border border-border/60">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((current) => !current)}
>
<span>高级生成设置</span>
<ChevronDown className={`size-4 shrink-0 opacity-60 transition-transform ${advancedOpen ? "rotate-180" : ""}`} />
</button>
{advancedOpen ? (
<div className="space-y-3 border-t border-border/60 px-3 pb-3 pt-3">
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">生成模型</div>
<ModelPicker
config={generationConfig}
value={generationConfig.imageModel || generationConfig.model}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
/>
</div>
<ImageSettingsPanel
config={generationConfig}
showTitle={false}
showCount={false}
bypassPriceGuard
className="space-y-3"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
/>
</div>
) : null}
</div>
<p className="text-sm text-muted-foreground">{size ? `${size.width} × ${size.height} px · 蓝色区域将被重新生成` : loadError || "正在读取图片…"}</p>
</section>
<section className="flex max-h-[68vh] flex-col gap-4 overflow-y-auto pr-1">
<Segmented block value={mode} onChange={setMode} options={[{ label: "画笔", value: "paint" }, { label: "擦除", value: "erase" }]} />
<label className="text-sm">笔刷直径 · {diameter} px<Slider min={8} max={160} step={2} value={diameter} onChange={setDiameter} /></label>
<div className="flex gap-2">
<Button disabled={!strokeCount} onClick={() => { strokes.current.pop(); setStrokeCount(strokes.current.length); repaint(); }}>撤销笔画</Button>
<Button onClick={() => { strokes.current = []; activePointer.current = null; setStrokeCount(0); setError(""); repaint(); }}>清空遮罩</Button>
</div>
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border/50 pt-3">
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
重置
</Button>
<div className="flex items-center gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>
取消
</Button>
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
AI 修改
</Button>
</div>
</div>
</div>
<label className="flex flex-col gap-2 text-sm">修改要求<Input.TextArea rows={4} value={prompt} placeholder="描述选中区域需要发生的变化" onChange={(event) => { setPrompt(event.target.value); setError(""); }} /></label>
<Collapse defaultActiveKey={["settings"]} items={[{ key: "settings", label: "生成设置", children: <div className="space-y-4">
<ModelPicker config={settings} value={settings.imageModel || settings.model} capability="image" fullWidth showSelectedPrice={false} onChange={(model) => setSettings((previous) => ({ ...previous, ...defaultImageParamsForModel(previous, model), imageModel: model, model }))} />
<ImageSettingsPanel config={settings} theme={theme} showTitle={false} showCount={false} bypassPriceGuard onConfigChange={(key, value) => setSettings((previous) => ({ ...previous, [key]: value }))} />
</div> }]} />
{error || loadError ? <p role="alert" className="text-sm text-destructive">{error || loadError}</p> : null}
</section>
</div>
</Modal>
</AppModal>
);
}
function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
const rect = canvas.getBoundingClientRect();
return {
x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width,
y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height,
};
}
function clearCanvas(canvas: HTMLCanvasElement | null) {
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
}
function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, size: number) {
if (from.x === to.x && from.y === to.y) {
context.beginPath();
context.arc(to.x, to.y, size / 2, 0, Math.PI * 2);
context.fill();
return;
}
context.beginPath();
context.moveTo(from.x, from.y);
context.lineTo(to.x, to.y);
context.stroke();
}
function canvasHasPaint(canvas: HTMLCanvasElement) {
const context = canvas.getContext("2d");
if (!context) return false;
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (let index = 3; index < data.length; index += 4) {
if (data[index] > 0) return true;
}
return false;
}
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
const context = previewCanvas?.getContext("2d");
if (!previewCanvas || !context) return;
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
context.fillStyle = maskFillColor;
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
context.globalCompositeOperation = "destination-in";
context.drawImage(maskCanvas, 0, 0);
context.globalCompositeOperation = "source-over";
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
}
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return;
const { width, height } = maskCanvas;
const data = maskContext.getImageData(0, 0, width, height).data;
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
const dash = step * 8;
const gap = step * 5;
const period = dash + gap;
context.save();
context.fillStyle = maskBorderColor;
context.shadowColor = "rgba(0, 0, 0, .24)";
context.shadowBlur = step * 1.5;
for (let y = step; y < height - step; y += step) {
for (let x = step; x < width - step; x += step) {
const offset = (y * width + x) * 4 + 3;
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
if ((x + y) % period > dash) continue;
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
}
}
context.restore();
}
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
}
function buildEditMask(selectionCanvas: HTMLCanvasElement) {
const canvas = document.createElement("canvas");
canvas.width = selectionCanvas.width;
canvas.height = selectionCanvas.height;
const context = canvas.getContext("2d");
if (!context) return selectionCanvas.toDataURL("image/png");
const selectionContext = selectionCanvas.getContext("2d");
context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.width, canvas.height);
if (!selectionContext) return canvas.toDataURL("image/png");
const selection = selectionContext.getImageData(0, 0, canvas.width, canvas.height);
const mask = context.getImageData(0, 0, canvas.width, canvas.height);
for (let index = 3; index < mask.data.length; index += 4) {
if (selection.data[index] > 0) mask.data[index] = 0;
}
context.putImageData(mask, 0, 0);
return canvas.toDataURL("image/png");
}

View File

@@ -1,102 +1,51 @@
import { useEffect, useState } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2 } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
import { useState } from "react";
import { InputNumber } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { MAX_IMAGE_GRID, planImageTiles, type ImageSplitParams } from "@/lib/canvas/image-operation-plan";
import { useImageToolSource } from "./use-image-tool-source";
export type CanvasImageSplitParams = ImageSplitParams;
type SplitDialogProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void };
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
const maxGridSize = 12;
export function CanvasNodeSplitDialog(props: SplitDialogProps) {
return props.open && props.dataUrl ? <SplitEditor key={props.dataUrl} {...props} /> : null;
}
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const total = params.rows * params.columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setImage(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
function SplitEditor({ dataUrl, onClose, onConfirm }: SplitDialogProps) {
const [grid, setGrid] = useState<ImageSplitParams>({ rows: 2, columns: 2 });
const { size, error } = useImageToolSource(dataUrl);
const valid = size !== null && size.width >= grid.columns && size.height >= grid.rows;
const tiles = valid ? planImageTiles(size, grid) : [];
const previewSize = tiles[0];
const updateCount = (axis: keyof ImageSplitParams, value: number | null) => {
if (value === null || !Number.isFinite(value)) return;
setGrid((previous) => ({ ...previous, [axis]: Math.min(MAX_IMAGE_GRID, Math.max(1, Math.round(value))) }));
};
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">宫格切分</h2>
<p className="mt-1 text-sm opacity-60">生成 {total} 个图片子节点,并按原图网格排列到画布右侧</p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid rows={params.rows} columns={params.columns} />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60">原图</span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60">子节点</span>
<span className="font-semibold">{total} 个</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60">单块约</span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
生成子节点
</Button>
<AppModal open title="宫格切分" width={780} centered onCancel={onClose} okText={`生成 ${grid.rows * grid.columns} 个子节点`} onOk={() => valid && onConfirm(grid)} okButtonProps={{ disabled: !valid }}>
<p className="mb-4 text-sm text-muted-foreground">按行、列拆分原图,子节点保持网格顺序。</p>
<div className="grid gap-4 md:grid-cols-[1fr_220px]">
<figure className="m-0 flex min-w-0 flex-col items-center gap-3 rounded-lg border border-border bg-surface-active p-3">
<div className="relative max-w-full overflow-hidden">
<img alt="待切分图片" src={dataUrl} draggable={false} className="block max-h-[340px] max-w-full object-contain" />
<svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{Array.from({ length: grid.columns - 1 }, (_, i) => <line key={`v${i}`} x1={(100 * (i + 1)) / grid.columns} x2={(100 * (i + 1)) / grid.columns} y1={0} y2={100} stroke="white" strokeWidth={1} vectorEffect="non-scaling-stroke" />)}
{Array.from({ length: grid.rows - 1 }, (_, i) => <line key={`h${i}`} y1={(100 * (i + 1)) / grid.rows} y2={(100 * (i + 1)) / grid.rows} x1={0} x2={100} stroke="white" strokeWidth={1} vectorEffect="non-scaling-stroke" />)}
</svg>
</div>
<figcaption className="text-sm text-muted-foreground">{size ? `${size.width} × ${size.height} px` : error || "正在读取图片…"}</figcaption>
</figure>
<div className="flex flex-col gap-4">
{(["rows", "columns"] as const).map((axis) => (
<label key={axis} className="flex flex-col gap-2 text-sm">
{axis === "rows" ? "行数" : "列数"}
<InputNumber aria-label={axis === "rows" ? "切分行数" : "切分列数"} min={1} max={MAX_IMAGE_GRID} precision={0} value={grid[axis]} onChange={(value) => updateCount(axis, value)} />
</label>
))}
<output className="text-sm text-muted-foreground">{previewSize ? `每块约 ${previewSize.width} × ${previewSize.height} px;边缘像素会完整保留。` : "等待有效图片尺寸"}</output>
{error || (size && !valid) ? <p role="alert" className="text-sm text-destructive">{error || "行列数超过图片像素尺寸,请减少切分数量"}</p> : null}
</div>
</div>
</Modal>
</AppModal>
);
}
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) {
return (
<label className="block space-y-2">
<span className="font-medium opacity-75">{label}</span>
<InputNumber className="w-full" min={1} max={maxGridSize} precision={0} value={value} onChange={onChange} />
</label>
);
}
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
return (
<div className="pointer-events-none absolute inset-0">
{Array.from({ length: columns - 1 }).map((_, index) => (
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
))}
{Array.from({ length: rows - 1 }).map((_, index) => (
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
))}
</div>
);
}
function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
}

View File

@@ -1,113 +1,49 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Modal, Segmented } from "antd";
import { ImagePlus } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data";
import { useState } from "react";
import { Radio, Select } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/image-operation-plan";
import { useImageToolSource } from "./use-image-tool-source";
export type CanvasImageUpscaleParams = ImageUpscaleParams;
type UpscaleDialogProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void };
const pixelTargets = [1024, 2048, MAX_UPSCALE_LONG_EDGE];
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
{ value: "high", title: "高质量插值", description: "适合照片和细节图" },
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
];
const targetOptions = [
{ label: "1K", value: 1024 },
{ label: "2K", value: 2048 },
{ label: "4K", value: MAX_UPSCALE_LONG_EDGE },
];
const defaultParams: CanvasImageUpscaleParams = {
targetLongEdge: 2048,
algorithm: "high",
};
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
const outputSize = useMemo(() => (image ? resolveUpscaleSize(image.width, image.height, params.targetLongEdge) : null), [image, params.targetLongEdge]);
const canUpscale = Boolean(image && sourceLongEdge < params.targetLongEdge && params.targetLongEdge <= MAX_UPSCALE_LONG_EDGE);
const reachedMax = Boolean(image && sourceLongEdge >= MAX_UPSCALE_LONG_EDGE);
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setImage(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!image) return;
const nextTarget = targetOptions.find((option) => sourceLongEdge < option.value)?.value || MAX_UPSCALE_LONG_EDGE;
setParams((current) => ({ ...current, targetLongEdge: nextTarget }));
}, [image, sourceLongEdge]);
export function CanvasNodeUpscaleDialog(props: UpscaleDialogProps) {
return props.open && props.dataUrl ? <ResizeEditor key={props.dataUrl} {...props} /> : null;
}
function ResizeEditor({ dataUrl, onClose, onConfirm }: UpscaleDialogProps) {
const { size, error } = useImageToolSource(dataUrl);
const [chosen, setChosen] = useState<number | null>(null);
const [algorithm, setAlgorithm] = useState<ImageUpscaleAlgorithm>("high");
const edge = size ? Math.max(size.width, size.height) : 0;
const target = chosen ?? pixelTargets.find((pixels) => pixels > edge) ?? MAX_UPSCALE_LONG_EDGE;
const output = size ? resolveUpscaleSize(size.width, size.height, target) : null;
const ready = Boolean(size && target > edge);
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">调整尺寸</h2>
<p className="mt-2 text-sm opacity-60">通过插值放大像素尺寸,另存为新图片,保留原图。这不是 AI 超分,不会生成新的真实细节。</p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[280px] place-items-center rounded-lg bg-black/5">
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60">源图</span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
<div className="space-y-6 py-2">
<div className="space-y-2">
<div className="font-medium opacity-75">目标像素</div>
<Segmented
block
value={params.targetLongEdge}
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
/>
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null}
</div>
<div className="space-y-2">
<div className="font-medium opacity-75">放大算法</div>
<Segmented
block
value={params.algorithm}
options={algorithms.map((item) => ({
value: item.value,
label: (
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
<span className="font-medium">{item.title}</span>
<span className="text-xs opacity-55">{item.description}</span>
</span>
),
}))}
onChange={(value) => setParams((current) => ({ ...current, algorithm: value as ImageUpscaleAlgorithm }))}
/>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60">输出尺寸</span>
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
生成放大图
</Button>
<AppModal open centered title="调整图片尺寸" width={760} onCancel={onClose} okText="生成放大图" okButtonProps={{ disabled: !ready }} onOk={() => ready && onConfirm({ targetLongEdge: target, algorithm })}>
<p className="mb-4 text-sm text-muted-foreground">插值放大并另存为新图片,原图保留。此操作不生成新的真实细节。</p>
<div className="grid gap-5 md:grid-cols-2">
<figure className="m-0 flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-surface-active p-3">
<img alt="待调整尺寸的图片" src={dataUrl} draggable={false} className="max-h-[320px] max-w-full object-contain" />
<figcaption className="text-sm text-muted-foreground">{size ? `原图 ${size.width} × ${size.height} px` : error || "正在读取图片…"}</figcaption>
</figure>
<div className="flex flex-col gap-5">
<label className="flex flex-col gap-2 text-sm">目标长边
<Select aria-label="目标长边" value={target} onChange={setChosen} options={pixelTargets.map((pixels) => ({ value: pixels, label: `${pixels} px`, disabled: pixels <= edge }))} />
</label>
<fieldset className="space-y-2">
<legend className="mb-2 text-sm">插值方式</legend>
<Radio.Group value={algorithm} onChange={(event) => setAlgorithm(event.target.value)} className="flex flex-col gap-2">
<Radio value="high">高质量 · 照片与细节图</Radio>
<Radio value="bilinear">双线性 · 平滑快速</Radio>
<Radio value="nearest">最近邻 · 像素图</Radio>
</Radio.Group>
</fieldset>
<output className="text-sm">{output ? `输出 ${output.width} × ${output.height} px` : "等待读取尺寸"}</output>
{error || (size && !ready) ? <p role="alert" className="text-sm text-destructive">{error || (edge >= MAX_UPSCALE_LONG_EDGE ? "图片已达到 4K,无需放大" : "请选择大于原图的目标尺寸")}</p> : null}
</div>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -13,10 +13,11 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
import { getNodeDefinition, getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import type { NodeResizeCorner } from "@/lib/canvas/node-resize-geometry";
import { useCanvasNodeResize } from "./use-canvas-node-resize";
import { CanvasNodeContent, CanvasNodeImageInfo, CanvasNodeProducedModel } from "./canvas-node-content";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type CanvasNodeProps = {
@@ -140,18 +141,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeRef = useRef({
isResizing: false,
corner: "bottom-right" as ResizeCorner,
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0,
startWidth: 0,
startHeight: 0,
keepRatio: false,
ratio: 1,
});
const handleResizePointerDown = useCanvasNodeResize(data, scale, scriptMinHeight, onResize, readOnly);
useEffect(() => {
const textarea = textareaRef.current;
@@ -188,81 +178,6 @@ export const CanvasNode = React.memo(function CanvasNode({
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
}, [isEditingContent]);
const handleResizeMove = useCallback(
(event: MouseEvent) => {
if (!resizeRef.current.isResizing) return;
const dx = (event.clientX - resizeRef.current.startX) / scale;
const dy = (event.clientY - resizeRef.current.startY) / scale;
const minSize = getNodeMinSize(data.type);
const minWidth = minSize.width;
// 分镜脚本的高度由表格内容动态撑开,覆盖注册表里的静态下限。
const minHeight = scriptMinHeight || minSize.height;
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
const fromLeft = resizeRef.current.corner.includes("left");
const fromTop = resizeRef.current.corner.includes("top");
const rawWidth = Math.max(minWidth, resizeRef.current.startWidth + (fromLeft ? -dx : dx));
const rawHeight = Math.max(minHeight, resizeRef.current.startHeight + (fromTop ? -dy : dy));
let width = rawWidth;
let height = rawHeight;
if (resizeRef.current.keepRatio) {
const ratio = resizeRef.current.ratio;
if (Math.abs(dx) >= Math.abs(dy)) {
height = width / ratio;
} else {
width = height * ratio;
}
if (height < minHeight) {
height = minHeight;
width = height * ratio;
}
if (width < minWidth) {
width = minWidth;
height = width / ratio;
}
}
onResize(data.id, width, height, {
x: fromLeft ? startRight - width : resizeRef.current.startLeft,
y: fromTop ? startBottom - height : resizeRef.current.startTop,
});
},
[data.id, data.type, onResize, scale, scriptMinHeight],
);
const handleResizeUp = useCallback(() => {
resizeRef.current.isResizing = false;
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
}, [handleResizeMove]);
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
event.stopPropagation();
event.preventDefault();
resizeRef.current = {
isResizing: true,
corner,
startX: event.clientX,
startY: event.clientY,
startLeft: data.position.x,
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: shouldKeepAspectRatio(data),
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
window.addEventListener("mouseup", handleResizeUp);
};
useEffect(() => {
return () => {
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
};
}, [handleResizeMove, handleResizeUp]);
const commitTitle = () => {
const next = titleDraft.trim();
setIsEditingTitle(false);
@@ -489,10 +404,10 @@ export const CanvasNode = React.memo(function CanvasNode({
) : null}
{showChrome && !readOnly && !data.metadata?.locked && (isSelected || hovered) ? <>
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-left" onPointerDown={handleResizePointerDown} />
<ResizeHandle corner="top-right" onPointerDown={handleResizePointerDown} />
<ResizeHandle corner="bottom-left" onPointerDown={handleResizePointerDown} />
<ResizeHandle corner="bottom-right" onPointerDown={handleResizePointerDown} />
</> : null}
</div>
@@ -631,7 +546,7 @@ function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvas
);
}
function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
function ResizeHandle({ corner, onPointerDown }: { corner: NodeResizeCorner; onPointerDown: (event: React.PointerEvent<HTMLDivElement>, corner: NodeResizeCorner) => void }) {
const positionClass = {
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
@@ -639,7 +554,7 @@ function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDo
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
}[corner];
return <div className={`absolute z-[var(--node-z-handle)] size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
return <div data-resize-corner={corner} className={`absolute z-[var(--node-z-handle)] size-7 touch-none ${positionClass}`} onPointerDown={(event) => onPointerDown(event, corner)} onMouseDown={(event) => event.stopPropagation()} />;
}
const NODE_EXTERNAL_HEADER_MIN_SCALE = 0.35;

View File

@@ -7,7 +7,7 @@ import { Link } from "react-router";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { getProject, getProjectUnit, type ProjectDetail, type ProjectUnit } from "@/services/api/projects";
export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-infinite-canvas-project-chapter";
export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-yingce-project-chapter";
export type CanvasProjectChapterPayload = Pick<ProjectUnit, "id" | "title" | "position"> & { projectId: string; sourceText?: string };
const CHAPTER_ROW_HEIGHT = 36;

View File

@@ -0,0 +1,38 @@
import { useId, useState, type ReactNode } from "react";
import { Button, Popover } from "antd";
import { Settings2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
export type CanvasSettingsPlacement = "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
type SettingsPopoverProps = {
label: string;
summary: string;
children: ReactNode;
placement?: CanvasSettingsPlacement;
buttonClassName?: string;
width?: number;
onOpenChange?: (open: boolean) => void;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
autoAdjustOverflow?: boolean;
};
export function CanvasSettingsPopover({ label, summary, children, placement = "topLeft", buttonClassName, width = 356, onOpenChange, getPopupContainer, autoAdjustOverflow = true }: SettingsPopoverProps) {
const [open, setOpen] = useState(false);
const id = useId();
const theme = canvasThemes[useActiveTheme()];
const change = (next: boolean) => { setOpen(next); onOpenChange?.(next); };
return (
<Popover open={open} onOpenChange={change} trigger="click" placement={placement} getPopupContainer={getPopupContainer} autoAdjustOverflow={autoAdjustOverflow}
arrow={false} destroyOnHidden styles={{ root: { zIndex: "var(--z-dialog-popover)" }, container: { background: theme.canvas.background, color: theme.node.text, padding: 12 } }}
content={<section id={id} aria-label={label} data-canvas-no-zoom data-canvas-wheel-scroll className="canvas-image-settings-popover overflow-y-auto overscroll-contain" style={{ width: `min(${width}px, calc(100vw - 48px))`, maxHeight: "min(560px, calc(100dvh - 100px))" }}
onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") change(false); }}>{children}</section>}>
<Button type="text" size="small" aria-expanded={open} aria-controls={open ? id : undefined} aria-label={`${label}:${summary}`} title={`${label} · ${summary}`}
className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`}
style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">{summary}</span>
</Button>
</Popover>
);
}

View File

@@ -1,65 +1,33 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useRef, useState } from "react";
import { Select } from "@/components/ui/base/select";
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
type CanvasSizePickerProps = {
value: string;
className?: string;
onChange: (value: string) => void;
};
type CanvasSizePickerProps = { value: string; className?: string; onChange: (value: string) => void };
const presets = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
const selectSize = (next: string) => {
onChange(next.trim());
setSearch("");
setOpen(false);
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState(false);
const draft = useRef("");
const options = [...new Set([...presets, value, query.trim()].filter(Boolean))].map((option) => ({ label: option, value: option }));
const commit = (text: string) => {
const next = text.trim();
draft.current = "";
setQuery("");
setExpanded(false);
if (next && next !== value) onChange(next);
};
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
setOpen(false);
};
window.addEventListener("pointerdown", close, true);
return () => window.removeEventListener("pointerdown", close, true);
}, [open]);
return (
<div ref={rootRef} className={className}>
<Select
showSearch
open={open}
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
value={value || undefined}
searchValue={search}
placeholder="比例"
options={options}
popupMatchSelectWidth={false}
popupRender={(menu) => (
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
onOpenChange={setOpen}
onSearch={setSearch}
onChange={selectSize}
onBlur={() => {
if (search.trim()) selectSize(search);
}}
<div className={className} data-canvas-no-zoom onPointerDown={(event) => event.stopPropagation()}>
<Select ariaLabel="生成比例" className="canvas-compact-control canvas-control-select h-full w-full" placeholder="比例" showSearch
value={value || undefined} options={options} searchValue={query} open={expanded} popupMatchSelectWidth={false}
onSearch={(text) => { draft.current = text; setQuery(text); }} onOpenChange={setExpanded}
onChange={commit} onBlur={() => { if (draft.current.trim()) commit(draft.current); else setExpanded(false); }}
onInputKeyDown={(event) => {
if (event.key === "Enter" && search.trim()) selectSize(search);
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && draft.current.trim()) { event.preventDefault(); commit(draft.current); }
if (event.key === "Escape") { draft.current = ""; setQuery(""); setExpanded(false); }
}}
/>
popupRender={(menu) => <div onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>{menu}</div>} />
</div>
);
}

View File

@@ -1,125 +1,20 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, resolveVideoRatioValue, resolveVideoResolutionValue } from "@/lib/model-capabilities";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasSettingsPopover, type CanvasSettingsPlacement } from "./canvas-settings-popover";
type CanvasVideoSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
type VideoPopoverProps = { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; buttonClassName?: string; placement?: CanvasSettingsPlacement };
export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) {
export function CanvasVideoSettingsPopover({ config, onConfigChange, ...popover }: VideoPopoverProps) {
const theme = canvasThemes[useActiveTheme()];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const videoProfile = modelCapabilityConfigFor(config, config.model).video;
const resolutionSupported = Boolean(videoProfile?.resolutions.length);
const sizeSupported = Boolean(videoProfile?.ratios.length);
const resolution = videoProfile ? resolveVideoResolutionValue(videoProfile, config.vquality) : "";
const size = videoProfile ? resolveVideoRatioValue(videoProfile, config.size) : "";
const summary = [
...(resolutionSupported ? [videoResolutionLabel(resolution)] : []),
...(sizeSupported ? [videoSizeLabel(size)] : []),
videoSecondsLabel(config.videoSeconds),
].join(" · ");
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <VideoSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`视频设置:${summary}`} title={`视频设置 · ${summary}`} onClick={() => setOpen((current) => !current)}>
<span className="truncate">{summary}</span>
</Button>
</span>
{panel}
</>
);
}
function VideoSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasVideoSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const gap = 8;
const margin = 12;
const width = Math.min(356, window.innerWidth - margin * 2);
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const estimatedHeight = 370;
const topSpace = buttonRect.top - gap - margin;
const bottomSpace = window.innerHeight - buttonRect.bottom - gap - margin;
const placeAbove = topPlacement ? topSpace >= estimatedHeight || topSpace >= bottomSpace : bottomSpace < estimatedHeight && topSpace > bottomSpace;
const style = {
position: "fixed",
zIndex: "var(--z-dialog-popover)",
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(placeAbove ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, topSpace) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, bottomSpace) }),
background: theme.canvas.background,
border: `1px solid ${theme.toolbar.border}`,
borderRadius: 10,
boxShadow: `0 24px 72px ${theme.spatial.shadow}`,
padding: 12,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover aceternity-floating-panel backdrop-blur-2xl"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<VideoSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-3" />
</div>,
document.body,
);
const profile = modelCapabilityConfigFor(config, config.model).video;
const parts: string[] = [];
if (profile?.resolutions.length) parts.push(videoResolutionLabel(resolveVideoResolutionValue(profile, config.vquality)));
if (profile?.ratios.length) parts.push(videoSizeLabel(resolveVideoRatioValue(profile, config.size)));
parts.push(videoSecondsLabel(config.videoSeconds));
return <CanvasSettingsPopover {...popover} label="视频设置" summary={parts.join(" · ")}>
<VideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} className="space-y-3" />
</CanvasSettingsPopover>;
}

View File

@@ -7,7 +7,7 @@ import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/c
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { ViewportTransform } from "@/types/canvas";
type InfiniteCanvasProps = {
type CanvasViewportProps = {
interactive?: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
@@ -45,7 +45,7 @@ type PinchState = {
initialScale: number;
};
export function InfiniteCanvas({ interactive = true, containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
export function CanvasViewport({ interactive = true, containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: CanvasViewportProps) {
const colorTheme = useActiveTheme();
const resolvedAppearance = resolveCanvasAppearance(appearance, colorTheme);
const panState = useRef({

View File

@@ -51,15 +51,25 @@ export function applyActorReferenceMaterial(root: Object3D, baseColor: string) {
uMetalness: { value: 0.05 },
},
vertexShader: `
#include <common>
#include <skinning_pars_vertex>
varying vec3 vNormal;
varying vec3 vViewPosition;
varying float vHeight;
void main() {
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
#include <beginnormal_vertex>
#include <skinbase_vertex>
#include <skinnormal_vertex>
#include <defaultnormal_vertex>
#include <begin_vertex>
#include <skinning_vertex>
vNormal = normalize(transformedNormal);
vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);
vViewPosition = -mvPosition.xyz;
vHeight = position.y;
vHeight = transformed.y;
gl_Position = projectionMatrix * mvPosition;
}
`,

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef, type PointerEvent } from "react";
import type { CanvasNodeData, Position } from "@/types/canvas";
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
import { solveNodeResize, type NodeResizeCorner } from "@/lib/canvas/node-resize-geometry";
type ResizeCallback = (id: string, width: number, height: number, position?: Position) => void;
export function useCanvasNodeResize(node: CanvasNodeData, scale: number, minimumHeight: number | null, onResize: ResizeCallback, readOnly = false) {
const dispose = useRef<(() => void) | null>(null);
const latest = useRef(onResize);
latest.current = onResize;
const disabled = readOnly || Boolean(node.metadata?.locked);
useEffect(() => {
if (disabled) dispose.current?.();
return () => dispose.current?.();
}, [node.id, disabled]);
return (event: PointerEvent<HTMLElement>, corner: NodeResizeCorner) => {
event.stopPropagation();
if (disabled || dispose.current || event.button !== 0 || !(scale > 0) || !Number.isFinite(scale)) return;
event.preventDefault();
const target: HTMLElement = event.currentTarget;
const pointer = event.pointerId;
const origin = { x: event.clientX, y: event.clientY };
let previous = origin;
const minimum = getNodeMinSize(node.type);
const snapshot = {
position: { ...node.position }, width: node.width, height: node.height, corner,
minimumWidth: minimum.width, minimumHeight: minimumHeight ?? minimum.height,
ratio: shouldKeepAspectRatio(node) ? (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1) : undefined,
};
const listeners = new AbortController();
const stop = () => {
listeners.abort();
if (target.hasPointerCapture(pointer)) target.releasePointerCapture(pointer);
dispose.current = null;
};
const move = (next: globalThis.PointerEvent) => {
if (next.pointerId !== pointer || (previous.x === next.clientX && previous.y === next.clientY)) return;
previous = { x: next.clientX, y: next.clientY };
const size = solveNodeResize(snapshot, { x: (next.clientX - origin.x) / scale, y: (next.clientY - origin.y) / scale });
latest.current(node.id, size.width, size.height, size.position);
};
target.setPointerCapture(pointer);
target.addEventListener("pointermove", move, { signal: listeners.signal });
target.addEventListener("pointerup", (next) => {
if (next.pointerId !== pointer) return;
try { move(next); } finally { stop(); }
}, { signal: listeners.signal });
target.addEventListener("pointercancel", (next) => { if (next.pointerId === pointer) stop(); }, { signal: listeners.signal });
target.addEventListener("lostpointercapture", (next) => { if (next.pointerId === pointer) stop(); }, { signal: listeners.signal });
window.addEventListener("blur", stop, { signal: listeners.signal });
dispose.current = stop;
};
}

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import { loadRasterImage } from "@/lib/image-raster";
import type { PixelSize } from "@/lib/canvas/image-operation-plan";
export function useImageToolSource(source: string) {
const [result, setResult] = useState<{ source: string; size: PixelSize | null; error: string }>({ source, size: null, error: "" });
useEffect(() => {
const request = new AbortController();
setResult({ source, size: null, error: "" });
void loadRasterImage(source, request.signal).then(
(image) => {
if (!request.signal.aborted) setResult({ source, size: { width: image.naturalWidth, height: image.naturalHeight }, error: "" });
},
(error: unknown) => {
if (!request.signal.aborted) setResult({ source, size: null, error: error instanceof Error ? error.message : "图片读取失败" });
},
);
return () => request.abort();
}, [source]);
return result.source === source ? result : { source, size: null, error: "" };
}

View File

@@ -6,7 +6,7 @@ import type { ChangeEvent } from "react";
import type { ChannelHeader } from "@/stores/use-config-store";
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
const DEFAULT_USER_AGENT = "Yingce/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
const MAX_HEADER_COUNT = 32;
const BLOCKED_HEADERS = new Set([
"authorization",

View File

@@ -1,45 +1,25 @@
import { useEffect, useState } from "react";
import { LoaderCircle } from "lucide-react";
import { formatDuration } from "@/lib/image-utils";
import { cn } from "@/lib/utils";
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
const [tick, setTick] = useState(0);
type PendingImageProps = { className?: string; label?: string; compact?: boolean };
export function ImageGenerationPending({ className, label = "正在生成图片", compact = false }: PendingImageProps) {
const [startedAt] = useState(() => Date.now());
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, []);
const index = Math.floor(tick / 2) % pendingMessages.length;
const progress = Math.min(98, 10 + (1 - Math.exp(-tick / 28)) * 88);
const update = () => setElapsed(Math.max(0, Date.now() - startedAt));
const clock = window.setInterval(update, 1000);
const onVisible = () => { if (!document.hidden) update(); };
document.addEventListener("visibilitychange", onVisible);
return () => { window.clearInterval(clock); document.removeEventListener("visibilitychange", onVisible); };
}, [startedAt]);
return (
<div className={cn("relative overflow-hidden bg-stone-100 dark:bg-white/10", compact ? "min-h-24" : "aspect-[4/3]", className)}>
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
backgroundSize: "16px 16px",
maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)",
}}
/>
<div className="absolute left-4 top-4 flex items-center gap-2 text-[var(--fs-body-lg)] font-medium text-stone-500 dark:text-stone-300">
<LoaderCircle className="size-4 animate-spin" />
<span>{label || pendingMessages[index]}</span>
</div>
<div className="absolute bottom-4 left-4 right-4">
<div className="mb-2 flex items-center justify-between text-xs text-stone-500 dark:text-stone-400">
<span>{formatDuration(tick * 1000)}</span>
<span>{Math.floor(progress)}%</span>
</div>
<div className="h-1.5 rounded-full bg-stone-300/70 dark:bg-white/12">
<div className="h-full rounded-full bg-stone-900 dark:bg-stone-100" style={{ width: `${progress}%` }} />
</div>
</div>
<div aria-busy="true" className={cn("flex flex-col items-center justify-center gap-3 rounded-lg bg-surface-active p-4 text-muted-foreground", compact ? "min-h-24" : "aspect-[4/3]", className)}>
<LoaderCircle aria-hidden="true" className="size-5 animate-spin motion-reduce:animate-none" />
<span role="status" className="text-sm font-medium">{label}</span>
<time className="text-xs tabular-nums" dateTime={`PT${Math.floor(elapsed / 1000)}S`}>已等待 {formatDuration(elapsed)}</time>
</div>
);
}

View File

@@ -10,14 +10,14 @@ import { useUserStore } from "@/stores/use-user-store";
export function ModelSetupGuide({ hidden = false }: { hidden?: boolean }) {
const reducedMotion = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const registrationGuide = typeof window !== "undefined" && window.sessionStorage.getItem("infinite-canvas:model-setup-guide") === "1";
const registrationGuide = typeof window !== "undefined" && window.sessionStorage.getItem("yingce:model-setup-guide") === "1";
const hydrated = useUserStore((state) => state.hydrated);
const user = useUserStore((state) => state.user);
const models = useConfigStore((state) => state.config.models);
if (hidden || dismissed || !hydrated || !user || user.role === "admin" || (!registrationGuide && models.length > 0)) return null;
const close = () => {
window.sessionStorage.removeItem("infinite-canvas:model-setup-guide");
window.sessionStorage.removeItem("yingce:model-setup-guide");
setDismissed(true);
};

View File

@@ -1,4 +1,4 @@
export const WORKSPACE_SIDEBAR_STORAGE_KEY = "infinite-canvas:workspace-sidebar-collapsed";
export const WORKSPACE_SIDEBAR_STORAGE_KEY = "yingce:workspace-sidebar-collapsed";
export const WORKSPACE_SIDEBAR_CHANGE_EVENT = "workspace:sidebar-collapsed-change";
type WorkspaceSidebarStorage = Pick<Storage, "getItem" | "setItem">;

View File

@@ -143,7 +143,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
return {
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
cssVar: { key: `infinite-canvas-${normalizeSkinID(skinID)}-${dark ? "dark" : "light"}` },
cssVar: { key: `yingce-${normalizeSkinID(skinID)}-${dark ? "dark" : "light"}` },
token: {
colorPrimary: color.solidBg,
colorPrimaryHover: color.solidHoverBg,

View File

@@ -29,7 +29,7 @@ export type ResolvedCanvasAppearance = {
export const DEFAULT_CANVAS_BACKGROUND_MODE: CanvasBackgroundMode = "dots";
const CANVAS_APPEARANCE_DEFAULT_KEY = "infinite-canvas:canvas-appearance-default";
const CANVAS_APPEARANCE_DEFAULT_KEY = "yingce:canvas-appearance-default";
const HEX_COLOR_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
const CUSTOM_GRID_COLOR: Record<CanvasColorTheme, string> = { light: "#000000", dark: "#000000" };
const CUSTOM_GRID_OPACITY: Record<CanvasColorTheme, number> = { light: 80, dark: 100 };

View File

@@ -32,9 +32,9 @@ export type CanvasDrawingRender = CanvasDrawingRenderDraft & {
updatedAt: string;
};
const drawingStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_documents" });
const drawingPreviewStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_previews" });
const drawingRenderStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_generation_renders" });
const drawingStore = localforage.createInstance({ name: "yingce", storeName: "drawing_documents" });
const drawingPreviewStore = localforage.createInstance({ name: "yingce", storeName: "drawing_previews" });
const drawingRenderStore = localforage.createInstance({ name: "yingce", storeName: "drawing_generation_renders" });
const INITIAL_DRAWING_RENDER_MAX_DIMENSION = 2048;
const INITIAL_DRAWING_RENDER_PADDING = 24;

View File

@@ -1,78 +1,46 @@
import { saveAs } from "file-saver";
import { createZip } from "@/lib/zip";
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { MediaPackage, downloadName, packageSegment, referencedStorageKeys } from "@/lib/media-package";
import { loadCanvasDrawing, loadCanvasDrawingPreview, loadCanvasDrawingRender } from "@/lib/canvas/canvas-drawing-storage";
import type { CanvasDrawingExport } from "@/types/canvas-export";
import type { CanvasDrawingExport, CanvasExportFile, CanvasProjectExportItem } from "@/types/canvas-export";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}) {
const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all(
projects.map(async (project) => {
const files: CanvasExportAsset[] = [];
await Promise.all(
collectStorageKeys(project).map(async (storageKey) => {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `projects/${project.id}/files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`;
files.push({ storageKey, path, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => options.includeLocalDrawings !== false && node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
const drawingId = node.metadata?.drawingId;
if (!drawingId) return null;
const [saved, preview, render] = await Promise.all([
loadCanvasDrawing(project.id, drawingId),
loadCanvasDrawingPreview(project.id, drawingId),
loadCanvasDrawingRender(project.id, drawingId),
]);
if (!saved) return null;
const previewPath = preview ? `projects/${project.id}/drawings/${safeFileName(drawingId)}.png` : undefined;
if (preview && previewPath) zipFiles.push({ name: previewPath, data: preview });
const generationRenderPath = render ? `projects/${project.id}/drawings/${safeFileName(drawingId)}.generation.png` : undefined;
if (render && generationRenderPath) zipFiles.push({ name: generationRenderPath, data: render.blob });
return {
drawingId,
...saved,
previewPath,
generationRender: render && generationRenderPath
? { path: generationRenderPath, pageId: render.pageId, width: render.width, height: render.height, mimeType: render.mimeType, background: render.background }
: undefined,
} satisfies CanvasDrawingExport;
}))).filter((item): item is CanvasDrawingExport => item !== null);
drawingDocuments.forEach((document) => zipFiles.push({ name: `projects/${project.id}/drawings/${safeFileName(document.drawingId)}.json`, data: JSON.stringify(document) }));
return { project, files, drawingDocuments };
}),
);
const data: CanvasExportFile = { app: "infinite-canvas", version: 4, exportedAt: new Date().toISOString(), projects: exportedProjects };
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, `${safeFileName(fileName)}.zip`);
async function addDrawing(archive: MediaPackage, project: CanvasProject, drawingId: string, directory: string): Promise<CanvasDrawingExport | null> {
const document = await loadCanvasDrawing(project.id, drawingId);
if (!document) return null;
const stem = `${directory}/drawings/${packageSegment(drawingId)}`;
const [preview, generation] = await Promise.all([loadCanvasDrawingPreview(project.id, drawingId), loadCanvasDrawingRender(project.id, drawingId)]);
const saved: CanvasDrawingExport = { ...document, drawingId };
if (preview) { saved.previewPath = `${stem}.png`; archive.put(saved.previewPath, preview); }
if (generation) {
const { pageId, width, height, mimeType, background } = generation;
saved.generationRender = { path: `${stem}.generation.png`, pageId, width, height, mimeType, background };
archive.put(saved.generationRender.path, generation.blob);
}
archive.put(`${stem}.json`, JSON.stringify(saved));
return saved;
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
return [...keys];
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("gltf-binary")) return "glb";
if (mimeType.includes("gltf+json")) return "gltf";
return storageKey.startsWith("image:") ? "png" : "bin";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}): Promise<void> {
const archive = new MediaPackage();
const items: CanvasProjectExportItem[] = [];
// Snapshot once so edits during asynchronous blob reads cannot change the manifest midway.
const snapshot = structuredClone(projects);
const projectIds = new Set<string>();
for (const project of snapshot) {
if (projectIds.has(project.id)) throw new Error("不能重复导出同一个画布项目");
projectIds.add(project.id);
const directory = `projects/${packageSegment(project.id)}`;
const files = await archive.collect(referencedStorageKeys(project), `${directory}/files`);
const drawingDocuments: CanvasDrawingExport[] = [];
if (options.includeLocalDrawings !== false) {
const ids = new Set(project.nodes.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
for (const drawingId of ids) {
const document = await addDrawing(archive, project, drawingId, directory);
if (document) drawingDocuments.push(document);
}
}
items.push({ project, files, drawingDocuments });
}
const manifest: CanvasExportFile = { app: "yingce", version: 4, exportedAt: new Date().toISOString(), projects: items };
saveAs(await archive.finish("projects.json", manifest), `${downloadName(fileName)}.zip`);
}

View File

@@ -1,182 +1,54 @@
export type ImageCropRect = {
x: number;
y: number;
width: number;
height: number;
import { loadRasterImage } from "@/lib/image-raster";
import { planImageTiles, resolveCropPixels, resolveUpscaleSize, type ImageCropRect, type ImageSplitParams, type ImageSplitPiece, type ImageUpscaleAlgorithm, type ImageUpscaleParams, type PixelSize } from "./image-operation-plan";
export { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize } from "./image-operation-plan";
export type { ImageCropRect, ImageSplitParams, ImageSplitPiece, ImageUpscaleAlgorithm, ImageUpscaleParams } from "./image-operation-plan";
type RasterCommand = {
region: ImageCropRect;
output: PixelSize;
sampling: ImageUpscaleAlgorithm;
};
export type ImageAngleTransform = {
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
};
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
export const MAX_UPSCALE_LONG_EDGE = 4096;
export type ImageUpscaleParams = {
targetLongEdge: number;
algorithm: ImageUpscaleAlgorithm;
};
export type ImageSplitParams = {
rows: number;
columns: number;
};
export type ImageSplitPiece = {
row: number;
column: number;
dataUrl: string;
};
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
const image = await loadImage(dataUrl);
if (crop) {
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
}
const size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2));
const sy = Math.max(0, Math.floor((image.height - size) / 2));
return drawCrop(image, sx, sy, size, size);
/** One raster pass shared by crop, grid extraction and resize. Failures never return the original as success. */
function renderPng(source: HTMLImageElement, command: RasterCommand): string {
const output = document.createElement("canvas");
Object.assign(output, command.output);
const painter = output.getContext("2d");
if (painter === null) throw new Error("浏览器无法创建图片处理画布");
const { region, sampling } = command;
painter.imageSmoothingEnabled = sampling !== "nearest";
painter.imageSmoothingQuality = sampling === "high" ? "high" : "low";
painter.drawImage(source, region.x, region.y, region.width, region.height, 0, 0, output.width, output.height);
const result = output.toDataURL("image/png");
if (!result.startsWith("data:image/png")) throw new Error("图片导出失败");
return result;
}
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
const image = await loadImage(dataUrl);
const rows = Math.max(1, Math.floor(params.rows));
const columns = Math.max(1, Math.floor(params.columns));
const pieces: ImageSplitPiece[] = [];
const dimensions = (image: HTMLImageElement): PixelSize => ({ width: image.naturalWidth, height: image.naturalHeight });
for (let row = 0; row < rows; row += 1) {
const sy = Math.floor((row * image.height) / rows);
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
for (let column = 0; column < columns; column += 1) {
const sx = Math.floor((column * image.width) / columns);
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
}
}
return pieces;
export async function cropDataUrl(source: string, selection?: ImageCropRect): Promise<string> {
const picture = await loadRasterImage(source);
const region = resolveCropPixels(dimensions(picture), selection);
return renderPng(picture, { region, output: { width: region.width, height: region.height }, sampling: "nearest" });
}
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
canvas.width = image.width + padding * 2;
canvas.height = image.height + padding * 2;
const context = canvas.getContext("2d");
if (!context) return dataUrl;
context.clearRect(0, 0, canvas.width, canvas.height);
const horizontal = params.horizontalAngle / 60;
const pitch = params.pitchAngle / 45;
const distanceScale = 1.12 - params.cameraDistance * 0.035;
const wideScale = params.wideAngle ? 0.88 : 1;
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const skewX = horizontal * image.width * 0.18;
const skewY = pitch * image.height * 0.12;
const x = cx - width / 2 + horizontal * padding * 0.5;
const y = cy - height / 2 + pitch * padding * 0.45;
context.save();
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
context.drawImage(image, x + skewX, y + skewY, width, height);
context.restore();
if (params.wideAngle) {
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas.toDataURL("image/png");
export async function splitDataUrl(source: string, grid: ImageSplitParams): Promise<ImageSplitPiece[]> {
const picture = await loadRasterImage(source);
return planImageTiles(dimensions(picture), grid).map((region) => ({
row: region.row,
column: region.column,
dataUrl: renderPng(picture, { region, output: { width: region.width, height: region.height }, sampling: "nearest" }),
}));
}
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
const image = await loadImage(dataUrl);
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
}
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
const longEdge = Math.max(1, width, height);
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
const scale = target / longEdge;
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
}
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw);
canvas.height = Math.max(1, sh);
const context = canvas.getContext("2d");
if (!context) return image.src;
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
let source: CanvasImageSource = image;
let sourceWidth = image.width;
let sourceHeight = image.height;
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
const nextWidth = sourceWidth * 2;
const nextHeight = sourceHeight * 2;
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
source = next;
sourceWidth = nextWidth;
sourceHeight = nextHeight;
}
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
}
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
}
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return canvas;
context.imageSmoothingEnabled = algorithm !== "nearest";
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
return canvas;
}
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
// 只有跨源 HTTP(S) 地址才需要声明匿名跨域;同源的 /api 资源不能
// 强制走 CORS,否则缺少 ACAO 响应头时反而会加载失败。
if (isCrossOriginHttpUrl(dataUrl)) image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
// 缺少 onerror 时加载失败会让 Promise 永久挂起,调用方 await 后表现为「点击无反应」。
image.onerror = () => reject(new Error("图片加载失败,无法处理该图片"));
image.src = dataUrl;
export async function upscaleDataUrl(source: string, options: ImageUpscaleParams): Promise<string> {
if (!["nearest", "bilinear", "high"].includes(options.algorithm)) throw new RangeError("不支持的图片插值方式");
const picture = await loadRasterImage(source);
const size = dimensions(picture);
return renderPng(picture, {
region: { ...size, x: 0, y: 0 },
output: resolveUpscaleSize(size.width, size.height, options.targetLongEdge),
sampling: options.algorithm,
});
}
function isCrossOriginHttpUrl(value: string) {
if (typeof window === "undefined") return false;
try {
const url = new URL(value, window.location.href);
return (url.protocol === "http:" || url.protocol === "https:") && url.origin !== window.location.origin;
} catch {
return false;
}
}

View File

@@ -0,0 +1,44 @@
import type { ImageCropRect } from "./image-operation-plan";
export type CropGrip = readonly [-1 | 0 | 1, -1 | 0 | 1];
const limit = (n: number, a: number, b: number) => Math.max(a, Math.min(b, n));
const minimum = 0.06;
export function translateSelection(box: ImageCropRect, dx: number, dy: number): ImageCropRect {
return { width: box.width, height: box.height, x: limit(box.x + dx, 0, 1 - box.width), y: limit(box.y + dy, 0, 1 - box.height) };
}
export function resizeSelection(box: ImageCropRect, grip: CropGrip, dx: number, dy: number, fixedAspect: boolean): ImageCropRect {
const [horizontal, vertical] = grip;
if (!horizontal && !vertical) return box;
if (!fixedAspect) {
const minWidth = Math.min(minimum, box.width);
const minHeight = Math.min(minimum, box.height);
const left = horizontal < 0 ? limit(box.x + dx, 0, box.x + box.width - minWidth) : box.x;
const right = horizontal > 0 ? limit(box.x + box.width + dx, box.x + minWidth, 1) : box.x + box.width;
const top = vertical < 0 ? limit(box.y + dy, 0, box.y + box.height - minHeight) : box.y;
const bottom = vertical > 0 ? limit(box.y + box.height + dy, box.y + minHeight, 1) : box.y + box.height;
return { x: left, y: top, width: right - left, height: bottom - top };
}
// Project the gesture onto the original rectangle's scale vector around its opposite anchor.
const fx = (1 - horizontal) / 2;
const fy = (1 - vertical) / 2;
const ax = box.x + box.width * fx;
const ay = box.y + box.height * fy;
const vx = horizontal * box.width;
const vy = vertical * box.height;
const proposed = 1 + (dx * vx + dy * vy) / (vx * vx + vy * vy);
const capacity = (anchor: number, fraction: number) => Math.min(fraction ? anchor / fraction : Infinity, fraction < 1 ? (1 - anchor) / (1 - fraction) : Infinity);
const ceiling = Math.min(capacity(ax, fx) / box.width, capacity(ay, fy) / box.height);
const floor = Math.min(ceiling, Math.max(minimum / box.width, minimum / box.height));
const factor = limit(proposed, floor, ceiling);
const width = box.width * factor;
const height = box.height * factor;
return { x: ax - width * fx, y: ay - height * fy, width, height };
}
export function selectAspect(box: ImageCropRect, normalizedRatio: number): ImageCropRect {
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) throw new RangeError("裁剪比例无效");
const height = Math.min(1, 1 / normalizedRatio, Math.sqrt((box.width * box.height) / normalizedRatio));
const width = height * normalizedRatio;
return { width, height, x: limit(box.x + (box.width - width) / 2, 0, 1 - width), y: limit(box.y + (box.height - height) / 2, 0, 1 - height) };
}

View File

@@ -0,0 +1,54 @@
export type MaskPoint = readonly [number, number];
export type MaskStroke = { diameter: number; erase: boolean; points: MaskPoint[] };
/** A stroke document is the source of truth; previews and exported masks are separate renders. */
export function paintMaskStrokes(canvas: HTMLCanvasElement, strokes: readonly MaskStroke[], color: string): CanvasRenderingContext2D {
const brush = canvas.getContext("2d", { willReadFrequently: true });
if (!brush) throw new Error("无法创建遮罩画布");
brush.clearRect(0, 0, canvas.width, canvas.height);
brush.lineCap = "round";
brush.lineJoin = "round";
brush.fillStyle = brush.strokeStyle = color;
for (const stroke of strokes) {
const first = stroke.points[0];
if (!first) continue;
brush.globalCompositeOperation = stroke.erase ? "destination-out" : "source-over";
brush.lineWidth = stroke.diameter;
const path = new Path2D();
if (stroke.points.length === 1) {
path.arc(first[0], first[1], stroke.diameter / 2, 0, Math.PI * 2);
brush.fill(path);
} else {
path.moveTo(first[0], first[1]);
for (const [x, y] of stroke.points.slice(1)) path.lineTo(x, y);
brush.stroke(path);
}
}
brush.globalCompositeOperation = "source-over";
return brush;
}
export function invertSelectionAlpha(selection: Uint8ClampedArray): Uint8ClampedArray {
if (selection.length % 4 !== 0) throw new RangeError("遮罩像素数据无效");
let selected = false;
const result = new Uint8ClampedArray(selection.length).fill(255);
for (let pixel = 0; pixel < selection.length / 4; pixel++) {
if (selection[pixel * 4 + 3] === 0) continue;
selected = true;
result[pixel * 4 + 3] = 0;
}
if (!selected) throw new Error("请先涂抹需要修改的区域");
return result;
}
export function exportEditMask(width: number, height: number, strokes: readonly MaskStroke[]): string {
const surface = document.createElement("canvas");
Object.assign(surface, { width, height });
const brush = paintMaskStrokes(surface, strokes, "black");
const pixels = brush.getImageData(0, 0, width, height);
pixels.data.set(invertSelectionAlpha(pixels.data));
brush.putImageData(pixels, 0, 0);
const png = surface.toDataURL("image/png");
if (!png.startsWith("data:image/png")) throw new Error("遮罩导出失败");
return png;
}

View File

@@ -0,0 +1,73 @@
export interface PixelSize {
width: number;
height: number;
}
/** Normalized image coordinates, independent of the preview's CSS dimensions. */
export interface ImageCropRect extends PixelSize {
x: number;
y: number;
}
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
export interface ImageUpscaleParams {
targetLongEdge: number;
algorithm: ImageUpscaleAlgorithm;
}
export interface ImageSplitParams {
rows: number;
columns: number;
}
export interface ImageSplitPiece {
row: number;
column: number;
dataUrl: string;
}
export const MAX_UPSCALE_LONG_EDGE = 4096;
export const MAX_IMAGE_GRID = 12;
export function assertPixelSize(size: PixelSize): void {
if (![size.width, size.height].every((n) => Number.isSafeInteger(n) && n > 0)) {
throw new RangeError("图片尺寸必须是正整数");
}
}
export function resolveUpscaleSize(width: number, height: number, requested: number): PixelSize {
assertPixelSize({ width, height });
if (!Number.isFinite(requested) || requested <= 0) throw new RangeError("目标尺寸无效");
const edge = Math.max(1, Math.min(MAX_UPSCALE_LONG_EDGE, Math.round(requested)));
return width >= height
? { width: edge, height: Math.max(1, Math.round((height / width) * edge)) }
: { width: Math.max(1, Math.round((width / height) * edge)), height: edge };
}
export function resolveCropPixels(size: PixelSize, selection?: ImageCropRect): ImageCropRect {
assertPixelSize(size);
if (selection === undefined) {
const side = Math.min(size.width, size.height);
return { width: side, height: side, x: Math.floor((size.width - side) / 2), y: Math.floor((size.height - side) / 2) };
}
const { x, y, width, height } = selection;
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) throw new RangeError("裁剪区域无效");
const left = Math.max(0, Math.floor(x * size.width));
const top = Math.max(0, Math.floor(y * size.height));
const right = Math.min(size.width, Math.ceil((x + width) * size.width));
const bottom = Math.min(size.height, Math.ceil((y + height) * size.height));
if (left >= right || top >= bottom) throw new RangeError("裁剪区域超出图片边界");
return { x: left, y: top, width: right - left, height: bottom - top };
}
/** Shared integer boundaries ensure every source pixel belongs to exactly one tile. */
export function planImageTiles(size: PixelSize, grid: ImageSplitParams): Array<ImageCropRect & { row: number; column: number }> {
assertPixelSize(size);
if (![grid.rows, grid.columns].every((n) => Number.isInteger(n) && n >= 1 && n <= MAX_IMAGE_GRID)) throw new RangeError("切分行列数必须在 1 到 12 之间");
if (grid.columns > size.width || grid.rows > size.height) throw new RangeError("切分数量不能超过图片像素尺寸");
const edges = (length: number, count: number) => Array.from({ length: count + 1 }, (_, i) => Math.floor((length * i) / count));
const horizontal = edges(size.width, grid.columns);
const vertical = edges(size.height, grid.rows);
return Array.from({ length: grid.rows * grid.columns }, (_, index) => {
const column = index % grid.columns;
const row = Math.floor(index / grid.columns);
return { column, row, x: horizontal[column], y: vertical[row], width: horizontal[column + 1] - horizontal[column], height: vertical[row + 1] - vertical[row] };
});
}

View File

@@ -0,0 +1,35 @@
import type { Position } from "@/types/canvas";
export type NodeResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
export type NodeResizeSnapshot = {
position: Position;
width: number;
height: number;
minimumWidth: number;
minimumHeight: number;
ratio?: number;
corner: NodeResizeCorner;
};
/** Solve size first, then retain the diagonally opposite world-space anchor. */
export function solveNodeResize(start: NodeResizeSnapshot, delta: Position): { width: number; height: number; position: Position } {
const sx = start.corner.endsWith("left") ? -1 : 1;
const sy = start.corner.startsWith("top") ? -1 : 1;
let width = Math.max(start.minimumWidth, start.width + sx * delta.x);
let height = Math.max(start.minimumHeight, start.height + sy * delta.y);
const ratio = start.ratio;
if (ratio !== undefined && Number.isFinite(ratio) && ratio > 0) {
// Preserve the existing dominant-pointer-axis gesture, including horizontal ties.
const requestedHeight = Math.abs(delta.x) >= Math.abs(delta.y) ? width / ratio : height;
height = Math.max(start.minimumHeight, start.minimumWidth / ratio, requestedHeight);
width = height * ratio;
}
return {
width,
height,
position: {
x: start.position.x + (sx < 0 ? start.width - width : 0),
y: start.position.y + (sy < 0 ? start.height - height : 0),
},
};
}

View File

@@ -0,0 +1,35 @@
export function loadRasterImage(source: string, signal?: AbortSignal): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const picture = new Image();
let finished = false;
const settle = (error?: Error) => {
if (finished) return;
finished = true;
clearTimeout(deadline);
signal?.removeEventListener("abort", cancel);
picture.onload = null;
picture.onerror = null;
if (error) {
picture.src = "";
reject(error);
} else resolve(picture);
};
const cancel = () => settle(new DOMException("图片读取已取消", "AbortError"));
const deadline = setTimeout(() => settle(new Error("图片读取超时")), 15000);
picture.onload = () => settle(picture.naturalWidth > 0 && picture.naturalHeight > 0 ? undefined : new Error("图片尺寸无效"));
picture.onerror = () => settle(new Error("图片加载失败,无法处理该图片"));
signal?.addEventListener("abort", cancel, { once: true });
if (signal?.aborted) return cancel();
if (!source.trim()) return settle(new Error("图片地址为空"));
try {
const location = globalThis.location;
if (location) {
const address = new URL(source, location.href);
if (["https:", "http:"].includes(address.protocol) && address.origin !== location.origin) picture.crossOrigin = "anonymous";
}
picture.src = source;
} catch (error) {
settle(error instanceof Error ? error : new Error("图片地址无效"));
}
});
}

View File

@@ -1,88 +1,83 @@
import type { ReferenceImage } from "@/types/image";
import { loadRasterImage } from "./image-raster";
export function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
export function formatBytes(bytes: number): string {
if (!(bytes > 0) || !Number.isFinite(bytes)) return "";
const exponent = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024)));
const power = Math.max(0, exponent);
const scaled = bytes / 1024 ** power;
return `${scaled.toFixed(power > 0 && scaled < 10 ? 1 : 0)} ${["B", "KB", "MB", "GB"][power]}`;
}
export function formatDuration(ms: number) {
const value = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(value / 60);
const seconds = value % 60;
return minutes ? `${minutes}分${String(seconds).padStart(2, "0")}秒` : `${seconds}秒`;
export function formatDuration(milliseconds: number): string {
const seconds = Number.isFinite(milliseconds) ? Math.max(0, Math.trunc(milliseconds / 1000)) : 0;
if (seconds < 60) return `${seconds}秒`;
return `${Math.trunc(seconds / 60)}分${String(seconds % 60).padStart(2, "0")}秒`;
}
export function getDataUrlByteSize(dataUrl: string) {
const base64 = dataUrl.split(",", 2)[1];
if (!base64) {
return 0;
}
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
}
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file);
});
}
export function readImageMeta(dataUrl: string, signal?: AbortSignal) {
return new Promise<{ width: number; height: number; mimeType: string }>((resolve, reject) => {
const image = new Image();
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const cleanup = (cancelLoad = false) => {
if (timer !== undefined) clearTimeout(timer);
image.onload = null;
image.onerror = null;
signal?.removeEventListener("abort", onAbort);
if (cancelLoad) image.src = "";
};
const done = (cancelLoad = false) => {
if (settled) return;
settled = true;
cleanup(cancelLoad);
resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" });
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup(true);
reject(new DOMException("The operation was aborted", "AbortError"));
};
image.onload = () => done();
image.onerror = () => done();
signal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => done(true), 3000);
if (signal?.aborted) {
onAbort();
return;
function decodeDataUrl(url: string): { bytes: Uint8Array<ArrayBuffer>; mimeType: string } {
const separator = url.indexOf(",");
if (!url.startsWith("data:") || separator < 0) throw new Error("无效的图片数据地址");
const parameters = url.slice(5, separator).split(";");
const payload = url.slice(separator + 1);
let bytes: Uint8Array<ArrayBuffer>;
if (parameters.includes("base64")) {
const binary = atob(payload.replace(/\s/g, ""));
bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
} else {
const encoded = new TextEncoder().encode(payload);
const output: number[] = [];
for (let cursor = 0; cursor < encoded.length; cursor++) {
if (encoded[cursor] !== 37) { output.push(encoded[cursor]); continue; }
const hex = String.fromCharCode(...encoded.slice(cursor + 1, cursor + 3));
if (!/^[0-9a-f]{2}$/i.test(hex)) throw new Error("图片数据包含无效的转义字符");
output.push(parseInt(hex, 16)); cursor += 2;
}
image.src = dataUrl;
});
bytes = Uint8Array.from(output);
}
return { bytes, mimeType: parameters[0] || "" };
}
export function dataUrlToFile(image: ReferenceImage) {
const [header, content] = image.dataUrl.split(",", 2);
const mimeType = header.match(/data:(.*?);base64/)?.[1] || image.type || "image/png";
const binary = atob(content || "");
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], image.name || "reference.png", { type: mimeType });
export function getDataUrlByteSize(url: string): number {
try {
const split = url.indexOf(",");
if (/;base64$/i.test(url.slice(0, split))) {
const payload = url.slice(split + 1).replace(/\s/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return 0;
return Math.floor(payload.replace(/=+$/, "").length * 0.75);
}
return decodeDataUrl(url).bytes.byteLength;
} catch { return 0; }
}
export async function readFileAsDataUrl(file: Blob): Promise<string> {
const bytes = new Uint8Array(await file.arrayBuffer());
const chunks: string[] = [];
for (let offset = 0; offset < bytes.length; offset += 16384) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)));
return `data:${file.type || "application/octet-stream"};base64,${btoa(chunks.join(""))}`;
}
/** Metadata probes retain their bounded fallback; editing uses strict loadRasterImage directly. */
export async function readImageMeta(source: string, signal?: AbortSignal): Promise<{ width: number; height: number; mimeType: string }> {
const mimeType = /^data:([^;,]+)/.exec(source)?.[1] || "image/png";
const request = new AbortController();
const cancel = () => request.abort();
const expired = setTimeout(cancel, 3000);
signal?.addEventListener("abort", cancel, { once: true });
if (signal?.aborted) cancel();
try {
const image = await loadRasterImage(source, request.signal);
return { width: image.naturalWidth, height: image.naturalHeight, mimeType };
} catch (failure) {
if (signal?.aborted) throw new DOMException("图片读取已取消", "AbortError");
return { width: 1024, height: 1024, mimeType };
} finally {
clearTimeout(expired);
signal?.removeEventListener("abort", cancel);
}
}
export function dataUrlToFile(image: ReferenceImage): File {
const decoded = decodeDataUrl(image.dataUrl);
return new File([decoded.bytes], image.name || "reference.png", { type: decoded.mimeType || image.type || "image/png" });
}

View File

@@ -0,0 +1,29 @@
type EventBuffer = { buffer: string };
/** Feed complete JSON data frames to a protocol adapter; retain a partial tail. */
export function consumeJsonEvents(state: EventBuffer, chunk: string, receive: (payload: unknown) => void, flush = false) {
state.buffer += chunk;
const input = state.buffer;
let offset = 0;
const boundaries = input.matchAll(/\r?\n\r?\n/g);
for (const boundary of boundaries) {
deliver(input.slice(offset, boundary.index), receive);
offset = boundary.index + boundary[0].length;
// Advance only after parsing succeeds, so an invalid frame isn't silently lost.
state.buffer = input.slice(offset);
}
if (flush) {
deliver(input.slice(offset), receive);
state.buffer = "";
}
}
function deliver(frame: string, receive: (payload: unknown) => void) {
const values: string[] = [];
for (const line of frame.split(/\r?\n/)) {
if (line.slice(0, 5) !== "data:") continue;
values.push(line.charAt(5) === " " ? line.slice(6) : line.slice(5));
}
const data = values.join("\n").trim();
if (data.length && data !== "[DONE]") receive(JSON.parse(data));
}

View File

@@ -4,7 +4,7 @@ import type { StateStorage } from "zustand/middleware";
import { isUserScopedPersistenceSuppressed, scopedStorageKey } from "@/lib/user-scope";
localforage.config({
name: "infinite-canvas",
name: "yingce",
storeName: "app_state",
});

View File

@@ -0,0 +1,56 @@
import { createZip } from "@/lib/zip";
import { getImageBlob } from "@/services/image-storage";
import { getMediaBlob } from "@/services/file-storage";
import type { CanvasExportAsset } from "@/types/canvas-export";
/** UTF-8 percent encoding keeps distinct storage IDs distinct inside an archive. */
export function packageSegment(value: string): string {
return encodeURIComponent(value).replace(/\./g, "%2E");
}
export function downloadName(value: string): string {
return value.replace(/[\x00-\x1f\\/:*?"<>|]/g, "_").trim() || "画布";
}
export class MediaPackage {
private readonly entries = new Map<string, BlobPart>();
put(path: string, data: BlobPart): void {
if (this.entries.has(path)) throw new Error(`导出文件路径重复:${path}`);
this.entries.set(path, data);
}
async collect(keys: Iterable<string>, directory: string, imageKeys?: ReadonlySet<string>): Promise<CanvasExportAsset[]> {
const assets: CanvasExportAsset[] = [];
for (const storageKey of new Set(keys)) {
const image = imageKeys ? imageKeys.has(storageKey) : storageKey.startsWith("image:");
const blob = await (image ? getImageBlob(storageKey) : getMediaBlob(storageKey));
// Cloud-backed references need not have a local blob and remain in the manifest.
if (!blob) continue;
const path = `${directory}/${packageSegment(storageKey)}`;
this.put(path, blob);
assets.push({ path, storageKey, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
}
return assets;
}
async finish(manifestPath: string, manifest: unknown): Promise<Blob> {
this.put(manifestPath, JSON.stringify(manifest, null, 2));
return createZip([...this.entries].map(([name, data]) => ({ name, data })));
}
}
export function referencedStorageKeys(root: unknown): Set<string> {
const result = new Set<string>();
const visited = new Set<object>();
const queue = [root];
while (queue.length) {
const item = queue.pop();
if (item === null || typeof item !== "object" || visited.has(item)) continue;
visited.add(item);
for (const [name, child] of Object.entries(item)) {
if (name === "storageKey" && typeof child === "string" && child.includes(":")) result.add(child);
else if (child && typeof child === "object") queue.push(child);
}
}
return result;
}

View File

@@ -1,7 +1,7 @@
import { localForageStorageForScope } from "@/lib/localforage-storage";
import type { PluginStorage } from "./plugin-types";
const PLUGIN_STORAGE_PREFIX = "infinite-canvas:plugin-storage:";
const PLUGIN_STORAGE_PREFIX = "yingce:plugin-storage:";
export function pluginStorageFor(pluginId: string): PluginStorage {
const storage = localForageStorageForScope();

View File

@@ -3,165 +3,84 @@ import { normalizeVideoDuration, VIDEO_DURATION_OPTIONS } from "@/lib/video-gene
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
export const SEEDANCE_REFERENCE_LIMITS = {
images: 9,
videos: 3,
audios: 3,
imageMaxBytes: 30 * 1024 * 1024,
videoMaxBytes: 50 * 1024 * 1024,
audioMaxBytes: 15 * 1024 * 1024,
};
export const seedanceResolutionOptions = [
{ value: "480p", label: "480P" },
{ value: "720p", label: "720P" },
{ value: "1080p", label: "1080P" },
] as const;
// Existing channel contract values; these are not a claim about every provider's current limits.
export const SEEDANCE_REFERENCE_LIMITS = { images: 9, videos: 3, audios: 3, imageMaxBytes: 30 * 1048576, videoMaxBytes: 50 * 1048576, audioMaxBytes: 15 * 1048576 };
export const seedanceResolutionOptions = [{ label: "480P", value: "480p" }, { label: "720P", value: "720p" }, { label: "1080P", value: "1080p" }] as const;
export const seedanceRatioOptions = [
{ value: "16:9", label: "横屏" },
{ value: "9:16", label: "竖屏" },
{ value: "1:1", label: "方形" },
{ value: "4:3", label: "标准横屏" },
{ value: "3:4", label: "标准竖屏" },
{ value: "21:9", label: "宽银幕" },
{ value: "adaptive", label: "自适应" },
{ label: "横屏", value: "16:9" }, { label: "竖屏", value: "9:16" }, { label: "方形", value: "1:1" },
{ label: "标准横屏", value: "4:3" }, { label: "标准竖屏", value: "3:4" }, { label: "宽银幕", value: "21:9" }, { label: "自适应", value: "adaptive" },
] as const;
export const seedanceDurationOptions = VIDEO_DURATION_OPTIONS;
const seedancePixels = {
"480p": {
"16:9": "864x496",
"4:3": "752x560",
"1:1": "640x640",
"3:4": "560x752",
"9:16": "496x864",
"21:9": "992x432",
},
"720p": {
"16:9": "1280x720",
"4:3": "1112x834",
"1:1": "960x960",
"3:4": "834x1112",
"9:16": "720x1280",
"21:9": "1470x630",
},
"1080p": {
"16:9": "1920x1080",
"4:3": "1664x1248",
"1:1": "1440x1440",
"3:4": "1248x1664",
"9:16": "1080x1920",
"21:9": "2206x946",
},
} as const;
const rasterRows: Record<string, readonly string[]> = {
"16:9": ["864x496", "1280x720", "1920x1080"], "4:3": ["752x560", "1112x834", "1664x1248"],
"1:1": ["640x640", "960x960", "1440x1440"], "3:4": ["560x752", "834x1112", "1248x1664"],
"9:16": ["496x864", "720x1280", "1080x1920"], "21:9": ["992x432", "1470x630", "2206x946"],
};
const ratios = Object.keys(rasterRows).map((label) => { const [w, h] = label.split(":").map(Number); return { label, ratio: w / h }; });
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">) {
const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
// Agent Plan 图片与视频共用 /api/plan/v3;按协议排除图片,避免 Seedream 误判为 Seedance。
if ("interfaceType" in requestConfig) {
const interfaceType = requestConfig.interfaceType;
if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-agent-plan-image") return false;
export function isSeedanceVideoModel(model: string): boolean { return /seedance/i.test(model); }
export function isSeedanceFastModel(model: string): boolean { return isSeedanceVideoModel(model) && /fast/i.test(model); }
export function isArkPlanBaseUrl(baseUrl: string): boolean { return /\/api\/plan\/v3/i.test(baseUrl); }
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">): boolean {
const request = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
if ("interfaceType" in request && ["volcengine-ark-image", "volcengine-ark-agent-plan-image"].includes(String(request.interfaceType))) return false;
return isArkPlanBaseUrl(request.baseUrl || "") || isSeedanceVideoModel(modelOptionName(request.model || request.videoModel));
}
export function normalizeResolutionToken(value: string): string {
const token = String(value || "").toLowerCase();
const aliases: Record<string, string> = { low: "480p", auto: "720p", high: "720p", medium: "720p", "4k": "2160p", "": "720p" };
return aliases[token] || `${token.replace(/p$/, "")}p`;
}
export function normalizeSeedanceResolution(value: string, model = ""): string {
const requested = normalizeResolutionToken(value);
const supported = isSeedanceFastModel(model) ? ["480p", "720p"] : ["480p", "720p", "1080p", "2160p"];
return supported.includes(requested) ? requested : "720p";
}
export function normalizeSeedanceDuration(value: string): number { return +normalizeVideoDuration(value); }
export function normalizeSeedanceRatio(value: string): string {
if (Object.hasOwn(rasterRows, value)) return value;
const size = /^(\d+)x(\d+)$/.exec(value);
if (!size || +size[1] === 0 || +size[2] === 0) return "adaptive";
const requested = +size[1] / +size[2];
let nearest = ratios[0];
let distance = Infinity;
for (const entry of ratios) {
const next = Math.abs(entry.ratio - requested);
if (next < distance) { distance = next; nearest = entry; }
}
return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl || "");
return nearest.label;
}
export function isSeedanceVideoModel(model: string) {
const value = model.toLowerCase();
return value.includes("seedance") || value.includes("doubao-seedance");
export function seedancePixelLabel(resolution: string, ratio: string): string {
const aspect = normalizeSeedanceRatio(ratio);
if (aspect === "adaptive") return "自动匹配";
const column = ["480p", "720p", "1080p"].indexOf(normalizeSeedanceResolution(resolution));
return column < 0 ? "" : rasterRows[aspect]?.[column] || "";
}
export function isSeedanceFastModel(model: string) {
const value = model.toLowerCase();
return isSeedanceVideoModel(value) && value.includes("fast");
export function boolConfig(value: string | undefined, fallback: boolean): boolean { return value === "true" || (value !== "false" && fallback); }
export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number): string {
return `${({ image: "图片", video: "视频", audio: "音频" })[kind]}${index + 1}`;
}
export function buildSeedancePromptText(prompt: string, _images: ReferenceImage[], _videos: ReferenceVideo[], _audios: ReferenceAudio[]): string { return prompt.trim(); }
export function isArkPlanBaseUrl(baseUrl: string) {
return baseUrl.toLowerCase().includes("ark.cn-beijing.volces.com/api/plan/v3") || baseUrl.toLowerCase().includes("/api/plan/v3");
}
export function normalizeSeedanceResolution(value: string, model = "") {
const normalized = normalizeResolutionToken(value);
if (isSeedanceFastModel(model) && (normalized === "1080p" || normalized === "2160p")) return "720p";
return normalized === "2160p" || seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p";
}
export function normalizeResolutionToken(value: string) {
if (value === "low") return "480p";
if (value === "auto" || value === "high" || value === "medium") return "720p";
if (value.toLowerCase() === "4k") return "2160p";
const resolution = String(value || "").replace(/p$/i, "") || "720";
return `${resolution}p`;
}
export function normalizeSeedanceDuration(value: string) {
return Number(normalizeVideoDuration(value));
}
export function normalizeSeedanceRatio(value: string) {
if (!value || value === "auto" || value === "adaptive") return "adaptive";
if (seedanceRatioOptions.some((item) => item.value === value)) return value;
const match = value.match(/^(\d+)x(\d+)$/);
if (!match) return "adaptive";
const width = Number(match[1]);
const height = Number(match[2]);
if (!width || !height) return "adaptive";
const ratio = width / height;
const options = [
["16:9", 16 / 9],
["4:3", 4 / 3],
["1:1", 1],
["3:4", 3 / 4],
["9:16", 9 / 16],
["21:9", 21 / 9],
] as const;
return options.reduce((best, item) => (Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best), options[0])[0];
}
export function seedancePixelLabel(resolution: string, ratio: string) {
const normalizedResolution = normalizeSeedanceResolution(resolution) as keyof typeof seedancePixels;
const normalizedRatio = normalizeSeedanceRatio(ratio) as keyof (typeof seedancePixels)[typeof normalizedResolution] | "adaptive";
if (normalizedRatio === "adaptive") return "自动匹配";
return seedancePixels[normalizedResolution][normalizedRatio] || "";
}
export function boolConfig(value: string | undefined, fallback: boolean) {
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number) {
if (kind === "image") return `图片${index + 1}`;
if (kind === "video") return `视频${index + 1}`;
return `音频${index + 1}`;
}
export function buildSeedancePromptText(prompt: string, _images: ReferenceImage[], _videos: ReferenceVideo[], _audios: ReferenceAudio[]) {
return prompt.trim();
}
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
let totalDurationMs = 0;
for (let index = 0; index < videos.length; index += 1) {
const video = videos[index];
const label = seedanceReferenceLabel("video", index);
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 50MB,请压缩后再上传`;
if (video.durationMs) {
if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`;
totalDurationMs += video.durationMs;
}
if (video.width && video.height) {
if (video.width < 300 || video.width > 6000 || video.height < 300 || video.height > 6000) return `${label} 宽高需要在 300-6000px 之间`;
const ratio = video.width / video.height;
if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`;
const pixels = video.width * video.height;
if (pixels < 640 * 640 || pixels > 2206 * 946) return `${label} 像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传`;
}
type VideoRule = { valid: (video: ReferenceVideo) => boolean; message: string };
const videoRules: VideoRule[] = [
{ valid: (v) => !v.bytes || v.bytes <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes, message: "超过 50MB,请压缩后再上传" },
{ valid: (v) => !v.durationMs || (v.durationMs >= 2000 && v.durationMs <= 15000), message: "时长需要在 2-15 秒之间" },
{ valid: (v) => !v.width || !v.height || [v.width, v.height].every((edge) => edge >= 300 && edge <= 6000), message: "宽高需要在 300-6000px 之间" },
{ valid: (v) => !v.width || !v.height || (v.width / v.height >= 0.4 && v.width / v.height <= 2.5), message: "宽高比需要在 0.4-2.5 之间" },
{ valid: (v) => !v.width || !v.height || (v.width * v.height >= 409600 && v.width * v.height <= 2086876), message: "像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传" },
];
export function seedanceVideoReferenceError(videos: ReferenceVideo[]): string {
for (const [index, video] of videos.entries()) {
const failed = videoRules.find((rule) => !rule.valid(video));
if (failed) return `${seedanceReferenceLabel("video", index)} ${failed.message}`;
}
if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒";
return "";
const duration = videos.reduce((total, video) => total + (video.durationMs || 0), 0);
return duration > 15000 ? "Seedance 参考视频总时长不能超过 15 秒" : "";
}
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";

View File

@@ -1,4 +1,4 @@
const ACTIVE_USER_SCOPE_KEY = "infinite-canvas:active-user-scope";
const ACTIVE_USER_SCOPE_KEY = "yingce:active-user-scope";
const GUEST_SCOPE = "guest";
let userScopedPersistenceSuppressionDepth = 0;

View File

@@ -10,7 +10,7 @@ import { type LucideIcon, FileText, Image as ImageIcon, Clapperboard, AudioLines
export type LibraryAsset = Asset;
export const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density";
export const ASSET_GRID_DENSITY_KEY = "yingce:asset-grid-density";
export const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
text: FileText,

View File

@@ -1,77 +1,46 @@
import { saveAs } from "file-saver";
import { createZip, readZip } from "@/lib/zip";
import { getMediaBlob, setMediaBlob } from "@/services/file-storage";
import { getImageBlob, setImageBlob } from "@/services/image-storage";
import { readZip } from "@/lib/zip";
import { MediaPackage } from "@/lib/media-package";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import type { Asset } from "@/stores/use-asset-store";
import type { CanvasExportAsset } from "@/types/canvas-export";
type AssetExportFile = {
app: "infinite-canvas";
version: 1;
exportedAt: string;
assets: Asset[];
files: AssetExportItem[];
};
type AssetExportFile = { app: "yingce"; version: 1; exportedAt: string; assets: Asset[]; files: CanvasExportAsset[] };
type AssetExportItem = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
export async function exportAssets(assets: Asset[]) {
const files: AssetExportItem[] = [];
const zipFiles: { name: string; data: BlobPart }[] = [];
await Promise.all(
assets.map(async (asset) => {
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return;
const storageKey = asset.data.storageKey;
if (!storageKey) return;
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `files/${safeFileName(storageKey)}.${fileExtension(blob.type, asset.kind)}`;
files.push({ storageKey, path, mimeType: blob.type || asset.data.mimeType, bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, "我的素材.zip");
export async function exportAssets(assets: Asset[]): Promise<void> {
const media = assets.filter((asset) => ["image", "video", "audio", "model"].includes(asset.kind));
const images = new Set<string>();
const keys: string[] = [];
for (const asset of media) {
if (!("storageKey" in asset.data) || typeof asset.data.storageKey !== "string" || !asset.data.storageKey) continue;
keys.push(asset.data.storageKey);
if (asset.kind === "image") images.add(asset.data.storageKey);
}
const archive = new MediaPackage();
const manifest: AssetExportFile = { app: "yingce", version: 1, assets, exportedAt: new Date().toISOString(), files: await archive.collect(keys, "files", images) };
saveAs(await archive.finish("assets.json", manifest), "我的素材.zip");
}
export async function readAssetPackage(file: File) {
const zip = await readZip(file);
const assetFile = zip.get("assets.json");
if (!assetFile) throw new Error("missing assets.json");
const data = JSON.parse(await assetFile.text()) as AssetExportFile;
await Promise.all(
data.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
);
return data.assets;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, kind: Asset["kind"]) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("mpeg")) return "mp3";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("gltf-binary")) return "glb";
if (mimeType.includes("gltf+json") || mimeType.includes("json")) return "gltf";
return kind === "image" ? "png" : "bin";
export async function readAssetPackage(file: File): Promise<Asset[]> {
const archive = await readZip(file);
const manifestBlob = archive.get("assets.json");
if (!manifestBlob) throw new Error("素材包缺少 assets.json");
const manifest: unknown = JSON.parse(await manifestBlob.text());
if (!manifest || typeof manifest !== "object" || !("app" in manifest) || manifest.app !== "yingce" || !("version" in manifest) || manifest.version !== 1 || !("assets" in manifest) || !Array.isArray(manifest.assets) || !("files" in manifest) || !Array.isArray(manifest.files)) throw new Error("素材包格式或版本不受支持");
const images = new Set<string>();
for (const asset of manifest.assets) {
if (!asset || typeof asset !== "object" || typeof asset.id !== "string" || !asset.data || typeof asset.data !== "object" || typeof asset.kind !== "string") throw new Error("素材包包含无效素材");
if (asset.kind === "image" && typeof asset.data.storageKey === "string") images.add(asset.data.storageKey);
}
// Validate the whole manifest before making the first persistent write.
const writes = manifest.files.map((entry: unknown) => {
if (!entry || typeof entry !== "object" || !("storageKey" in entry) || typeof entry.storageKey !== "string" || !entry.storageKey || !("path" in entry) || typeof entry.path !== "string" || !("mimeType" in entry) || typeof entry.mimeType !== "string") throw new Error("素材包文件索引无效");
const blob = archive.get(entry.path);
if (!blob) throw new Error(`素材包缺少文件:${entry.path}`);
if ("bytes" in entry && entry.bytes !== blob.size) throw new Error(`素材包文件大小不匹配:${entry.path}`);
return { key: entry.storageKey, blob: blob.slice(0, blob.size, entry.mimeType) };
});
for (const { key, blob } of writes) await (images.has(key) || key.startsWith("image:") ? setImageBlob(key, blob) : setMediaBlob(key, blob));
return manifest.assets as Asset[];
}

View File

@@ -77,7 +77,7 @@ export default function RegisterPage() {
await register({ username, ...(settings?.firstUser ? { email } : verification), displayName, password, acceptedTerms: agreementAccepted });
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
if (!settings?.firstUser) window.sessionStorage.setItem("infinite-canvas:model-setup-guide", "1");
if (!settings?.firstUser) window.sessionStorage.setItem("yingce:model-setup-guide", "1");
message.success(settings?.firstUser ? "管理员账号已创建" : "注册成功");
navigate(next, { replace: true });
} catch (error) {

View File

@@ -2,7 +2,7 @@ import { EmptyState } from "@/components/ui/product/empty-state";
import { Button, Spin } from "antd";
import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
@@ -124,7 +124,7 @@ export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { pr
{/* No editor callbacks, drawing cache key, or action context may cross this boundary. */}
<CanvasNodeActionContext.Provider value={readOnlyActions}>
<CanvasNodeGraphContext.Provider value={graph}>
<InfiniteCanvas
<CanvasViewport
containerRef={containerRef}
viewport={viewport}
appearance={project.appearance}
@@ -180,7 +180,7 @@ export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { pr
/>
),
)}
</InfiniteCanvas>
</CanvasViewport>
</CanvasNodeGraphContext.Provider>
</CanvasNodeActionContext.Provider>
{!nodes.length ? (

View File

@@ -59,7 +59,7 @@ import { CanvasPrevisTemplateModal } from "@/components/canvas/previs/canvas-pre
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-modal";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { handleListGenerate } from "./list-mode-generator";
@@ -196,10 +196,10 @@ export default function CanvasPage() {
if (!mounted) return <CanvasRefreshShell />;
return <InfiniteCanvasPage />;
return <CanvasViewportPage />;
}
function InfiniteCanvasPage() {
function CanvasViewportPage() {
// 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
@@ -2609,7 +2609,7 @@ function InfiniteCanvasPage() {
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="relative min-w-0 flex-1 overflow-hidden">
<InfiniteCanvas
<CanvasViewport
interactive={!versions.preview}
containerRef={containerRef}
viewport={viewport}
@@ -2725,7 +2725,7 @@ function InfiniteCanvasPage() {
/>
</CanvasNodeGraphContext.Provider>
</CanvasNodeActionContext.Provider>
</InfiniteCanvas>
</CanvasViewport>
<CanvasActiveTaskPanel tasks={activeTasks} onCancelTask={cancelCanvasTask} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />

View File

@@ -10,7 +10,7 @@ import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
@@ -258,7 +258,7 @@ export default function SharedCanvasPage() {
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
</header>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<CanvasViewport containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
</svg>
@@ -280,7 +280,7 @@ export default function SharedCanvasPage() {
dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false };
document.body.style.cursor = "grabbing";
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
</CanvasViewport>
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} onNineGrid={unauthorized} />

View File

@@ -1,301 +1,218 @@
import axios from "axios";
import { nanoid } from "nanoid";
import { consumeJsonEvents } from "@/lib/json-event-stream";
import type {
ChatCompletionPayload,
ChatCompletionStreamState,
GeminiPart,
GeminiPayload,
GeminiStreamState,
ImageApiResponse,
ResponseApiOutputItem,
ResponseApiPayload,
ResponseStreamState,
ResponseToolCall,
ToolResponseResult,
ChatCompletionPayload, ChatCompletionStreamState, GeminiPart, GeminiPayload, GeminiStreamState,
ImageApiResponse, ResponseApiPayload, ResponseStreamState, ResponseToolCall, ToolResponseResult,
} from "@/services/api/image-contracts";
function resolveImageDataUrl(item: Record<string, unknown>) {
if (typeof item.b64_json === "string" && item.b64_json) return `data:image/png;base64,${item.b64_json}`;
if (typeof item.url === "string" && item.url) return item.url;
return null;
type TextSink = (text: string) => void;
type TextState = { text: string; reasoning: string };
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
export function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
const images =
payload.data
?.map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
if (images.length === 0) throw new Error("接口没有返回图片");
return images;
}
export function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || readStatusError(error.response?.status, fallback);
}
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? error.message : fallback;
}
export function readStatusError(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}:${status}` : fallback;
}
export function parseChatCompletionPayload(payload: ChatCompletionPayload): ToolResponseResult {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (payload.error?.message) throw new Error(payload.error.message);
const message = payload.choices?.[0]?.message;
const toolCalls = (message?.tool_calls || [])
.map((call) => ({
id: call.id || "",
type: "function" as const,
function: { name: call.function?.name || "", arguments: call.function?.arguments || "{}" },
}))
.filter((call) => call.id && call.function.name);
return { content: message?.content || "", toolCalls, ...(message?.reasoning_content ? { reasoning: message.reasoning_content } : {}) };
}
export function parseToolResponse(payload: ResponseApiPayload): ToolResponseResult {
const output = payload.output || [];
const content =
payload.output_text ||
output
.flatMap((item) => (item.type === "message" ? item.content || [] : []))
.map((item) => item.text || "")
.join("");
const toolCalls = output
.filter((item): item is Extract<ResponseApiOutputItem, { type?: "function_call" }> => item.type === "function_call")
.map((item) => ({
id: item.call_id || item.id || "",
type: "function" as const,
function: { name: item.name || "", arguments: item.arguments || "{}" },
}))
.filter((item) => item.id && item.function.name);
return { content, toolCalls };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function stringValue(value: unknown) {
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function responseErrorMessage(value: unknown) {
if (!isRecord(value)) return "";
const error = isRecord(value.error) ? value.error : undefined;
const response = isRecord(value.response) ? value.response : undefined;
const responseError = response && isRecord(response.error) ? response.error : undefined;
return stringValue(value.msg) || stringValue(error?.message) || stringValue(responseError?.message);
}
export function validateResponsePayload(payload: ResponseApiPayload) {
function assertEnvelope(payload: { code?: number; msg?: string }) {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (payload.error?.message) throw new Error(payload.error.message);
}
export function validateGeminiPayload(payload: GeminiPayload) {
if (payload.error?.message) throw new Error(payload.error.message);
if (payload.promptFeedback?.blockReason) throw new Error(`Gemini 拒绝了本次请求:${payload.promptFeedback.blockReason}`);
function imagesFrom(sources: Iterable<string | undefined | null>, emptyMessage: string) {
const images: { id: string; dataUrl: string }[] = [];
for (const dataUrl of sources) if (dataUrl) images.push({ id: nanoid(), dataUrl });
if (!images.length) throw new Error(emptyMessage);
return images;
}
export async function readFetchError(response: Response, fallback: string) {
const text = await response.text();
if (!text) return readStatusError(response.status, fallback);
try {
return responseErrorMessage(JSON.parse(text)) || readStatusError(response.status, fallback);
} catch {
return text.slice(0, 300) || readStatusError(response.status, fallback);
}
function addTool(result: ToolResponseResult, id: string | undefined, name: string | undefined, args: string | undefined, signature?: string) {
if (!id || !name) return;
const call: ResponseToolCall = { type: "function", id, function: { name, arguments: args || "{}" } };
if (signature) call.thoughtSignature = signature;
result.toolCalls.push(call);
}
export async function readJsonPayload<T>(response: Response, fallback: string): Promise<T> {
const text = await response.text();
try {
return JSON.parse(text) as T;
} catch {
if (/^\s*(?:<!doctype|<html)/i.test(text)) throw new Error("后端代理返回了前端网页,请检查 VITE_CANVAS_BACKEND_URL 和反向代理配置");
throw new Error(`${fallback}:接口没有返回有效 JSON`);
}
function* geminiParts(payload: GeminiPayload): Generator<GeminiPart> {
for (const candidate of payload.candidates ?? []) yield* candidate.content?.parts ?? [];
}
function parseEventBlock(block: string) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n")
.trim();
return data && data !== "[DONE]" ? data : undefined;
export function parseImagePayload(payload: ImageApiResponse) {
assertEnvelope(payload);
function* sources() {
for (const item of payload.data ?? []) {
const encoded = text(item.b64_json);
yield encoded ? `data:image/png;base64,${encoded}` : text(item.url);
}
}
return imagesFrom(sources(), "接口没有返回图片");
}
function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
const data = parseEventBlock(block);
if (!data) return;
const event = JSON.parse(data) as Record<string, unknown>;
const type = stringValue(event.type);
const errorMessage = responseErrorMessage(event);
if (errorMessage) state.error = errorMessage;
if (type === "response.output_text.delta" && typeof event.delta === "string") {
state.text += event.delta;
onDelta?.(state.text);
}
if (type === "response.output_text.done" && !state.text && typeof event.text === "string") {
state.text = event.text;
onDelta?.(state.text);
}
if (["response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta"].includes(type) && typeof event.delta === "string") {
state.reasoning += event.delta;
onReasoning?.(state.reasoning);
}
if (["response.reasoning.done", "response.reasoning_text.done", "response.reasoning_summary_text.done"].includes(type) && !state.reasoning && typeof event.text === "string") {
state.reasoning = event.text;
onReasoning?.(state.reasoning);
}
if (type === "response.completed" && isRecord(event.response)) state.payload = event.response as ResponseApiPayload;
else if (Array.isArray(event.output)) state.payload = event as ResponseApiPayload;
export function parseChatCompletionPayload(payload: ChatCompletionPayload): ToolResponseResult {
validateResponsePayload(payload);
const message = payload.choices?.[0]?.message;
const result: ToolResponseResult = { content: message?.content || "", toolCalls: [] };
if (message?.reasoning_content) result.reasoning = message.reasoning_content;
for (const call of message?.tool_calls ?? []) addTool(result, call.id, call.function?.name, call.function?.arguments);
return result;
}
export function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
const index = match.index ?? 0;
consumeResponseStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
state.buffer = state.buffer.slice(index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeResponseStreamBlock(state.buffer, state, onDelta, onReasoning);
state.buffer = "";
}
}
function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
const data = parseEventBlock(block);
if (!data) return;
const event = JSON.parse(data) as Record<string, unknown>;
const errorMessage = responseErrorMessage(event);
if (errorMessage) state.error = errorMessage;
const choices = Array.isArray(event.choices) ? event.choices : [];
const choice = isRecord(choices[0]) ? choices[0] : undefined;
const delta = choice && isRecord(choice.delta) ? choice.delta : undefined;
if (!delta) return;
if (typeof delta.content === "string") {
state.text += delta.content;
onDelta?.(state.text);
}
const reasoningDelta = stringValue(delta.reasoning_content) || stringValue(delta.reasoning) || stringValue(delta.reasoning_text);
if (reasoningDelta) {
state.reasoning += reasoningDelta;
onReasoning?.(state.reasoning);
}
const chunks = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
chunks.forEach((value, fallbackIndex) => {
if (!isRecord(value)) return;
const callIndex = typeof value.index === "number" ? value.index : fallbackIndex;
const current = state.toolCalls.get(callIndex) || { id: "", name: "", arguments: "" };
const fn = isRecord(value.function) ? value.function : undefined;
state.toolCalls.set(callIndex, {
id: stringValue(value.id) || current.id,
name: stringValue(fn?.name) || current.name,
arguments: current.arguments + stringValue(fn?.arguments),
});
});
}
export function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
const index = match.index ?? 0;
consumeChatCompletionStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
state.buffer = state.buffer.slice(index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeChatCompletionStreamBlock(state.buffer, state, onDelta, onReasoning);
state.buffer = "";
}
}
function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
const data = parseEventBlock(block);
if (!data) return;
const result = parseGeminiToolResponse(JSON.parse(data) as GeminiPayload);
if (result.reasoning) {
state.reasoning += result.reasoning;
onReasoning?.(state.reasoning);
}
if (result.content) {
state.text += result.content;
onDelta?.(state.text);
}
state.toolCalls.push(...result.toolCalls);
}
export function consumeGeminiStreamText(state: GeminiStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
const index = match.index ?? 0;
consumeGeminiStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
state.buffer = state.buffer.slice(index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeGeminiStreamBlock(state.buffer, state, onDelta, onReasoning);
state.buffer = "";
export function parseToolResponse(payload: ResponseApiPayload): ToolResponseResult {
const result: ToolResponseResult = { content: "", toolCalls: [] };
for (const item of payload.output ?? []) {
switch (item.type) {
case "message":
for (const part of item.content ?? []) result.content += part.text || "";
break;
case "function_call":
addTool(result, item.call_id || item.id, item.name, item.arguments);
break;
}
}
if (payload.output_text) result.content = payload.output_text;
return result;
}
export function parseGeminiToolResponse(payload: GeminiPayload): ToolResponseResult {
validateGeminiPayload(payload);
const parts = payload.candidates?.flatMap((candidate) => candidate.content?.parts || []) || [];
const content = parts
.filter((part) => !part.thought)
.map((part) => part.text || "")
.join("");
const reasoning = parts
.filter((part) => part.thought)
.map((part) => part.text || "")
.join("");
const toolCalls = parts
.map((part) => part.functionCall)
.filter((call): call is NonNullable<GeminiPart["functionCall"]> => Boolean(call?.name))
.map((call) => {
const part = parts.find((item) => item.functionCall === call);
const thoughtSignature = part?.thoughtSignature || part?.thought_signature;
return {
id: call.id || nanoid(),
type: "function" as const,
function: { name: call.name || "", arguments: JSON.stringify(call.args || {}) },
...(thoughtSignature ? { thoughtSignature } : {}),
};
});
return { content, toolCalls, ...(reasoning ? { reasoning } : {}) };
const result: ToolResponseResult = { content: "", toolCalls: [] };
let reasoning = "";
for (const part of geminiParts(payload)) {
if (part.thought) reasoning += part.text || "";
else result.content += part.text || "";
const call = part.functionCall;
if (call?.name) addTool(result, call.id || nanoid(), call.name, JSON.stringify(call.args || {}), part.thoughtSignature || part.thought_signature);
}
if (reasoning) result.reasoning = reasoning;
return result;
}
export function parseGeminiImagePayload(payload: GeminiPayload) {
validateGeminiPayload(payload);
const images =
payload.candidates
?.flatMap((candidate) => candidate.content?.parts || [])
.map((part) => {
const inlineData = part.inlineData || (part.inline_data ? { mimeType: part.inline_data.mimeType || part.inline_data.mime_type, data: part.inline_data.data } : undefined);
if (inlineData?.data) return `data:${inlineData.mimeType || "image/png"};base64,${inlineData.data}`;
return part.fileData?.fileUri || null;
})
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
if (!images.length) throw new Error("Gemini 接口没有返回图片");
return images;
function* sources() {
for (const part of geminiParts(payload)) {
const inline = part.inlineData ?? part.inline_data;
const mime = part.inlineData ? part.inlineData.mimeType : part.inline_data?.mimeType || part.inline_data?.mime_type;
yield inline?.data ? `data:${mime || "image/png"};base64,${inline.data}` : part.fileData?.fileUri;
}
}
return imagesFrom(sources(), "Gemini 接口没有返回图片");
}
export function responseErrorMessage(value: unknown) {
const payload = record(value);
return text(payload.msg) || text(record(payload.error).message) || text(record(record(payload.response).error).message);
}
export function validateResponsePayload(payload: ResponseApiPayload) {
assertEnvelope(payload);
if (payload.error?.message) throw new Error(payload.error.message);
}
export function validateGeminiPayload(payload: GeminiPayload) {
const failure = payload.error?.message || (payload.promptFeedback?.blockReason ? `Gemini 拒绝了本次请求:${payload.promptFeedback.blockReason}` : "");
if (failure) throw new Error(failure);
}
export function readStatusError(status: number | undefined, fallback: string) {
const known: Record<number, string> = {
401: "鉴权失败,请检查 API Key、套餐权限或模型权限",
403: "鉴权失败,请检查 API Key、套餐权限或模型权限",
429: "请求被限流或额度不足,请稍后重试",
};
return status ? known[status] || `${fallback}:${status}` : fallback;
}
export function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error) || (error instanceof DOMException && error.name === "AbortError")) return "请求已取消";
if (!axios.isAxiosError(error)) return error instanceof Error ? error.message : fallback;
const payload = record(error.response?.data);
return text(payload.msg) || text(record(payload.error).message) || readStatusError(error.response?.status, fallback);
}
export async function readFetchError(response: Response, fallback: string) {
const body = await response.text();
const status = readStatusError(response.status, fallback);
if (!body) return status;
let payload: unknown;
try { payload = JSON.parse(body); } catch { return body.slice(0, 300) || status; }
return responseErrorMessage(payload) || status;
}
export async function readJsonPayload<T>(response: Response, fallback: string): Promise<T> {
const body = await response.text();
try { return JSON.parse(body) as T; } catch {
const detail = /^\s*(?:<!doctype|<html)/i.test(body)
? "后端代理返回了前端网页,请检查 VITE_CANVAS_BACKEND_URL 和反向代理配置"
: `${fallback}:接口没有返回有效 JSON`;
throw new Error(detail);
}
}
function updateText(state: TextState, field: keyof TextState, value: unknown, sink: TextSink | undefined, final = false) {
if (typeof value !== "string" || (final && state[field])) return;
state[field] = final ? value : state[field] + value;
sink?.(state[field]);
}
const responseChannels: Record<string, keyof TextState> = {
"response.output_text": "text",
"response.reasoning": "reasoning",
"response.reasoning_text": "reasoning",
"response.reasoning_summary_text": "reasoning",
};
export function consumeResponseStreamText(state: ResponseStreamState, chunk: string, onDelta?: TextSink, onReasoning?: TextSink, flush = false) {
consumeJsonEvents(state, chunk, (payload) => {
const event = record(payload);
const failure = responseErrorMessage(event);
if (failure) state.error = failure;
const type = text(event.type);
const separator = type.lastIndexOf(".");
const field = Object.hasOwn(responseChannels, type.slice(0, separator)) ? responseChannels[type.slice(0, separator)] : undefined;
const action = type.slice(separator + 1);
if (field && (action === "delta" || action === "done")) {
updateText(state, field, action === "done" ? event.text : event.delta, field === "text" ? onDelta : onReasoning, action === "done");
}
if (type === "response.completed" && event.response !== null && typeof event.response === "object" && !Array.isArray(event.response)) {
state.payload = event.response as ResponseApiPayload;
} else if (Array.isArray(event.output)) state.payload = event as ResponseApiPayload;
}, flush);
}
export function consumeChatCompletionStreamText(state: ChatCompletionStreamState, chunk: string, onDelta?: TextSink, onReasoning?: TextSink, flush = false) {
consumeJsonEvents(state, chunk, (payload) => {
const event = record(payload);
const failure = responseErrorMessage(event);
if (failure) state.error = failure;
const first = Array.isArray(event.choices) ? record(event.choices[0]) : {};
const delta = record(first.delta);
updateText(state, "text", delta.content, onDelta);
const reasoning = text(delta.reasoning_content) || text(delta.reasoning) || text(delta.reasoning_text);
if (reasoning) updateText(state, "reasoning", reasoning, onReasoning);
if (!Array.isArray(delta.tool_calls)) return;
for (const [fallback, raw] of delta.tool_calls.entries()) {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
const call = record(raw);
const index = typeof call.index === "number" ? call.index : fallback;
const previous = state.toolCalls.get(index);
const fn = record(call.function);
state.toolCalls.set(index, {
id: text(call.id) || previous?.id || "",
name: text(fn.name) || previous?.name || "",
arguments: (previous?.arguments || "") + text(fn.arguments),
});
}
}, flush);
}
export function consumeGeminiStreamText(state: GeminiStreamState, chunk: string, onDelta?: TextSink, onReasoning?: TextSink, flush = false) {
consumeJsonEvents(state, chunk, (payload) => {
const result = parseGeminiToolResponse(payload as GeminiPayload);
if (result.reasoning) updateText(state, "reasoning", result.reasoning, onReasoning);
if (result.content) updateText(state, "text", result.content, onDelta);
state.toolCalls.push(...result.toolCalls);
}, flush);
}

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { readFileAsDataUrl } from "@/lib/image-utils";
import type { ApiEnvelope, ApiVideoResponse, RequestOptions, SeedanceTask, VideoGenerationResult } from "./video-contracts";
@@ -54,29 +55,23 @@ export async function assertVideoBlob(blob: Blob) {
export function delay(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (aborted: boolean) => {
clearTimeout(timer);
signal?.removeEventListener("abort", cancel);
if (aborted) reject(new DOMException("Aborted", "AbortError"));
else resolve();
};
const cancel = () => finish(true);
if (signal?.aborted) { cancel(); return; }
signal?.addEventListener("abort", cancel, { once: true });
timer = setTimeout(() => finish(false), ms);
});
}
export function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取本地素材失败"));
reader.readAsDataURL(blob);
});
export async function blobToDataUrl(blob: Blob) {
try { return await readFileAsDataUrl(blob); }
catch { throw new Error("读取本地素材失败"); }
}
export async function videoResultFromUrl(url: string, options?: RequestOptions): Promise<VideoGenerationResult> {

View File

@@ -6,8 +6,8 @@ import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/can
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject };
const draftKey = (projectId: string) => `infinite-canvas:sync-drafts:${projectId}`;
const draftIndexKey = "infinite-canvas:sync-draft-index";
const draftKey = (projectId: string) => `yingce:sync-drafts:${projectId}`;
const draftIndexKey = "yingce:sync-draft-index";
export async function readAllCanvasSyncDrafts(scope = getActiveUserScope()) {
const raw = await localForageStorageForScope(scope).getItem(draftIndexKey);

View File

@@ -26,7 +26,7 @@ export type UploadedFile = {
remoteUploadError?: string;
};
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "media_files" });
const store = localforage.createInstance({ name: "yingce", storeName: "media_files" });
const objectUrls = new Map<string, string>();
export async function uploadMediaFile(input: Blob, prefix = "file", onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise<UploadedFile> {

View File

@@ -11,8 +11,8 @@ type GenerationStorageLockOptions = {
requireCrossRealmLock?: boolean;
};
const ASSET_STORAGE_LOCK_PREFIX = "infinite-canvas:generation-asset-storage-lock:";
const ARTIFACT_COMMIT_LOCK_PREFIX = "infinite-canvas:generation-artifact-commit-lock:";
const ASSET_STORAGE_LOCK_PREFIX = "yingce:generation-asset-storage-lock:";
const ARTIFACT_COMMIT_LOCK_PREFIX = "yingce:generation-artifact-commit-lock:";
const assetStorageTails = new Map<string, Promise<void>>();
const artifactCommitTails = new Map<string, Promise<void>>();

View File

@@ -23,7 +23,7 @@ export type UploadedImage = {
remoteUploadError?: string;
};
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "image_files" });
const store = localforage.createInstance({ name: "yingce", storeName: "image_files" });
const objectUrls = new Map<string, string>();
export async function uploadImage(input: string | Blob, onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise<UploadedImage> {

View File

@@ -33,8 +33,8 @@ type EffectStorage = {
setItem(name: string, value: string): unknown | Promise<unknown>;
};
const EFFECT_STORAGE_PREFIX = "infinite-canvas:generation-effect:";
const EFFECT_LOCK_PREFIX = "infinite-canvas:generation-effect-lock:";
const EFFECT_STORAGE_PREFIX = "yingce:generation-effect:";
const EFFECT_LOCK_PREFIX = "yingce:generation-effect-lock:";
const DEFAULT_LEASE_MS = 30_000;
const inProcessRecords = new Map<string, string>();
const inProcessLockTails = new Map<string, Promise<void>>();

View File

@@ -13,8 +13,8 @@ type ResourceCacheMeta = {
lastAccessedAt: number;
};
const blobStore = localforage.createInstance({ name: "infinite-canvas", storeName: "resource_blobs" });
const metaStore = localforage.createInstance({ name: "infinite-canvas", storeName: "resource_blob_meta" });
const blobStore = localforage.createInstance({ name: "yingce", storeName: "resource_blobs" });
const metaStore = localforage.createInstance({ name: "yingce", storeName: "resource_blob_meta" });
const objectUrls = new Map<string, string>();
const sessionBlobs = new Map<string, Blob>();
const inFlight = new Map<string, Promise<string>>();

View File

@@ -20,7 +20,7 @@ type CanvasHistoryStore = {
clearDeletedHistory: () => void;
};
export const CANVAS_HISTORY_STORE_KEY = "infinite-canvas:deleted_history_store";
export const CANVAS_HISTORY_STORE_KEY = "yingce:deleted_history_store";
const historyStorage: PersistStorage<CanvasHistoryStore> = {
getItem: async (name) => {

View File

@@ -47,7 +47,7 @@ type CanvasStore = {
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
export const CANVAS_STORE_KEY = "infinite-canvas:canvas_store";
export const CANVAS_STORE_KEY = "yingce:canvas_store";
type PersistedCanvasState = Pick<CanvasStore, "projects">;
type QueuedCanvasPersist = {
name: string;
@@ -85,7 +85,7 @@ type CanvasStorageLockOptions = {
requireCrossRealmLock?: boolean;
};
const CANVAS_STORAGE_LOCK_PREFIX = "infinite-canvas:canvas-generation-storage-lock:";
const CANVAS_STORAGE_LOCK_PREFIX = "yingce:canvas-generation-storage-lock:";
const canvasStorageTails = new Map<string, Promise<void>>();
function runWithBrowserCanvasStorageLock<T>(scope: string, operation: () => Promise<T>, options: CanvasStorageLockOptions) {

View File

@@ -12,7 +12,7 @@ export const useCanvasThemeStore = create<CanvasThemeStore>()(
persist(
(set) => ({ theme: DEFAULT_CANVAS_COLOR_THEME, active: false, setTheme: (theme) => { if (theme === "light" || theme === "dark") set({ theme }); } }),
{
name: "infinite-canvas:canvas-theme",
name: "yingce:canvas-theme",
partialize: ({ theme }) => ({ theme }),
merge: (persisted, current) => {
const theme = (persisted as Partial<CanvasThemeStore> | null)?.theme;

View File

@@ -75,7 +75,7 @@ type AssetStore = {
cleanupImages: (extra?: unknown) => Promise<void>;
};
export const ASSET_STORE_KEY = "infinite-canvas:asset_store";
export const ASSET_STORE_KEY = "yingce:asset_store";
type PersistedAssetState = Pick<AssetStore, "assets">;
type ObservedAssetPersist = {

View File

@@ -5,7 +5,7 @@ import { localForageStorageForScope } from "@/lib/localforage-storage";
import type { PluginInstallation, PluginManifest, PluginManifestV2 } from "@/lib/plugins/plugin-types";
import type { PluginState } from "@/services/api/plugins";
export const PLUGIN_STORE_KEY = "infinite-canvas:plugin-store";
export const PLUGIN_STORE_KEY = "yingce:plugin-store";
type PluginStore = {
hydrated: boolean;

View File

@@ -18,7 +18,7 @@ export const useThemeStore = create<ThemeStore>()(
setTheme: (next) => set((state) => (VALID_THEMES.includes(next) ? { theme: next } : state)),
}),
{
name: "infinite-canvas:theme_store",
name: "yingce:theme_store",
// 持久化恢复校验:旧版本/坏 session 写入的非法值回退到 light,
// 避免 canvasThemes[非法值] = undefined 触发 "reading 'node'" 崩溃
merge: (persisted, current) => {

View File

@@ -2,7 +2,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import type { CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
export type CanvasExportFile = {
app: "infinite-canvas";
app: "yingce";
version: 3 | 4;
exportedAt: string;
projects: CanvasProjectExportItem[];