460 lines
23 KiB
TypeScript
460 lines
23 KiB
TypeScript
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { LoaderCircle, ScanFace, SquareDashedMousePointer, X } from "lucide-react";
|
|
|
|
import { CanvasNodeEmotionPanel, type CanvasEmotionCharacter, type CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
|
|
import { CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
|
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
|
import { aceternityMotion } from "@/lib/aceternity-motion";
|
|
import { canvasThemes } from "@/lib/canvas-theme";
|
|
import { buildEmotionImageArtifacts, buildEmotionPrompt, neutralEmotionPreset, type CanvasEmotionPreset, type CanvasFaceBox } from "@/lib/canvas/canvas-emotion";
|
|
import { detectCanvasFaces } from "@/lib/canvas/canvas-face-detection";
|
|
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
|
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
|
import type { CanvasNodeData, Position, ViewportTransform } from "@/types/canvas";
|
|
|
|
type WorkspaceStatus = "detecting" | "selecting" | "manual" | "editing" | "generating" | "error";
|
|
|
|
type CanvasEmotionWorkspaceProps = {
|
|
node: CanvasNodeData;
|
|
viewport: ViewportTransform;
|
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
dragOffset?: Position | null;
|
|
isDragging?: boolean;
|
|
onClose: () => void;
|
|
onConfirm: (payload: CanvasImageEmotionPayload) => void;
|
|
};
|
|
|
|
export function CanvasEmotionWorkspace({ node, viewport, containerRef, dragOffset, isDragging = false, onClose, onConfirm }: CanvasEmotionWorkspaceProps) {
|
|
const dataUrl = node.metadata?.content || "";
|
|
const [status, setStatus] = useState<WorkspaceStatus>("detecting");
|
|
const [faces, setFaces] = useState<CanvasFaceBox[]>([]);
|
|
const [characters, setCharacters] = useState<CanvasEmotionCharacter[]>([]);
|
|
const [activeCharacterId, setActiveCharacterId] = useState("");
|
|
const [preset, setPreset] = useState<CanvasEmotionPreset>(neutralEmotionPreset);
|
|
const [imageSize, setImageSize] = useState({ width: node.metadata?.naturalWidth || 0, height: node.metadata?.naturalHeight || 0 });
|
|
const [error, setError] = useState("");
|
|
const [manualDraft, setManualDraft] = useState<CanvasFaceBox | null>(null);
|
|
const portalTarget = containerRef.current;
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setStatus("detecting");
|
|
setError("");
|
|
setFaces([]);
|
|
setCharacters([]);
|
|
setActiveCharacterId("");
|
|
setPreset(neutralEmotionPreset);
|
|
void detectCanvasFaces(dataUrl, controller.signal)
|
|
.then((result) => {
|
|
setImageSize({ width: result.imageWidth, height: result.imageHeight });
|
|
setFaces(result.faces);
|
|
if (result.faces.length) {
|
|
setStatus("selecting");
|
|
return;
|
|
}
|
|
setStatus("error");
|
|
setError("未识别到清晰人脸,请手动框选");
|
|
})
|
|
.catch((reason) => {
|
|
if (reason instanceof DOMException && reason.name === "AbortError") return;
|
|
setStatus("error");
|
|
setError(reason instanceof Error ? `${reason.message},请手动框选` : "人脸识别失败,请手动框选");
|
|
});
|
|
return () => controller.abort();
|
|
}, [dataUrl]);
|
|
|
|
const selectFace = (face: CanvasFaceBox) => {
|
|
const existing = characters.find((character) => sameFace(character.faceBox, face));
|
|
if (existing) {
|
|
setActiveCharacterId(existing.id);
|
|
setStatus("editing");
|
|
return;
|
|
}
|
|
const index = characters.length + 1;
|
|
const character = { id: `character-${face.id}`, name: node.metadata?.characterName && !characters.length ? node.metadata.characterName : `角色${index}`, faceBox: face };
|
|
setCharacters((current) => [...current, character]);
|
|
setActiveCharacterId(character.id);
|
|
setStatus("editing");
|
|
setError("");
|
|
};
|
|
|
|
const beginManualSelection = () => {
|
|
setStatus("manual");
|
|
setManualDraft(null);
|
|
setError("");
|
|
};
|
|
|
|
const confirmGeneration = async () => {
|
|
const character = characters.find((item) => item.id === activeCharacterId);
|
|
if (!character || !imageSize.width || !imageSize.height) return;
|
|
setStatus("generating");
|
|
setError("");
|
|
try {
|
|
const artifacts = await buildEmotionImageArtifacts(dataUrl, character.faceBox, imageSize.width, imageSize.height);
|
|
const params = { presetId: preset.id, intimacy: preset.intimacy, arousal: preset.arousal, characterName: character.name, faceBox: character.faceBox };
|
|
onConfirm({
|
|
...params,
|
|
label: preset.label,
|
|
prompt: buildEmotionPrompt(params, artifacts.editRegion),
|
|
sourceDataUrl: artifacts.sourceDataUrl,
|
|
maskDataUrl: artifacts.maskDataUrl,
|
|
characterDataUrl: artifacts.characterDataUrl,
|
|
editRegion: artifacts.editRegion,
|
|
imageWidth: artifacts.imageWidth,
|
|
imageHeight: artifacts.imageHeight,
|
|
});
|
|
} catch (reason) {
|
|
setStatus("editing");
|
|
setError(reason instanceof Error ? reason.message : "生成前处理失败");
|
|
}
|
|
};
|
|
|
|
if (!portalTarget || !dataUrl) return null;
|
|
const activeCharacter = characters.find((character) => character.id === activeCharacterId);
|
|
return createPortal(
|
|
<>
|
|
<FaceSelectionOverlay
|
|
node={node}
|
|
viewport={viewport}
|
|
containerRef={containerRef}
|
|
imageWidth={imageSize.width}
|
|
imageHeight={imageSize.height}
|
|
faces={faces}
|
|
characters={characters}
|
|
activeCharacterId={activeCharacterId}
|
|
status={status}
|
|
manualDraft={manualDraft}
|
|
onManualDraftChange={setManualDraft}
|
|
onManualComplete={(face) => {
|
|
setFaces((current) => [...current, face]);
|
|
selectFace(face);
|
|
setManualDraft(null);
|
|
}}
|
|
onFaceSelect={selectFace}
|
|
/>
|
|
<SelectionToolbar node={node} viewport={viewport} containerRef={containerRef} status={status} faceCount={faces.length} error={error} onManualSelect={beginManualSelection} onClose={onClose} />
|
|
<AnimatePresence>
|
|
{activeCharacter && (status === "editing" || status === "generating") ? (
|
|
<CanvasNodePanelOverlay node={node} viewport={viewport} containerRef={containerRef} panelWidth={580} panelHeight={303} dragOffset={dragOffset} isDragging={isDragging}>
|
|
<CanvasNodeEmotionPanel
|
|
dataUrl={dataUrl}
|
|
imageWidth={imageSize.width}
|
|
imageHeight={imageSize.height}
|
|
characters={characters}
|
|
activeCharacterId={activeCharacterId}
|
|
preset={preset}
|
|
generating={status === "generating"}
|
|
error={error}
|
|
onSelectCharacter={(id) => {
|
|
setActiveCharacterId(id);
|
|
setError("");
|
|
}}
|
|
onManualSelect={beginManualSelection}
|
|
onPresetChange={setPreset}
|
|
onClose={onClose}
|
|
onConfirm={() => void confirmGeneration()}
|
|
/>
|
|
</CanvasNodePanelOverlay>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</>,
|
|
portalTarget,
|
|
);
|
|
}
|
|
|
|
function FaceSelectionOverlay({
|
|
node,
|
|
viewport,
|
|
containerRef,
|
|
imageWidth,
|
|
imageHeight,
|
|
faces,
|
|
characters,
|
|
activeCharacterId,
|
|
status,
|
|
manualDraft,
|
|
onManualDraftChange,
|
|
onManualComplete,
|
|
onFaceSelect,
|
|
}: {
|
|
node: CanvasNodeData;
|
|
viewport: ViewportTransform;
|
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
imageWidth: number;
|
|
imageHeight: number;
|
|
faces: CanvasFaceBox[];
|
|
characters: CanvasEmotionCharacter[];
|
|
activeCharacterId: string;
|
|
status: WorkspaceStatus;
|
|
manualDraft: CanvasFaceBox | null;
|
|
onManualDraftChange: (box: CanvasFaceBox | null) => void;
|
|
onManualComplete: (box: CanvasFaceBox) => void;
|
|
onFaceSelect: (box: CanvasFaceBox) => void;
|
|
}) {
|
|
const theme = canvasThemes[useActiveTheme()];
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
useScreenAnchor(overlayRef, node, viewport, containerRef, (next) => imageScreenRect(node, next, imageWidth, imageHeight));
|
|
if (!imageWidth || !imageHeight) return null;
|
|
const interactive = status !== "detecting" && status !== "generating";
|
|
const faceIsSelected = (face: CanvasFaceBox) => characters.find((character) => sameFace(character.faceBox, face));
|
|
const pointerPosition = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
const bounds = event.currentTarget.getBoundingClientRect();
|
|
return { x: ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * imageWidth, y: ((event.clientY - bounds.top) / Math.max(1, bounds.height)) * imageHeight };
|
|
};
|
|
return (
|
|
<div
|
|
ref={overlayRef}
|
|
data-canvas-no-zoom
|
|
className={`absolute z-[var(--z-modal)] overflow-hidden rounded-[var(--r-2xl)] ${status === "manual" ? "cursor-crosshair touch-none" : "pointer-events-none"}`}
|
|
style={{ left: 0, top: 0, width: node.width * viewport.k, height: node.height * viewport.k }}
|
|
onPointerDown={
|
|
status === "manual"
|
|
? (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
const start = pointerPosition(event);
|
|
dragStartRef.current = start;
|
|
onManualDraftChange({ id: `manual-${Date.now()}`, x: start.x, y: start.y, width: 0, height: 0, source: "manual" });
|
|
}
|
|
: undefined
|
|
}
|
|
onPointerMove={
|
|
status === "manual"
|
|
? (event) => {
|
|
const start = dragStartRef.current;
|
|
if (!start) return;
|
|
const current = pointerPosition(event);
|
|
onManualDraftChange({
|
|
id: manualDraft?.id || `manual-${Date.now()}`,
|
|
x: Math.min(start.x, current.x),
|
|
y: Math.min(start.y, current.y),
|
|
width: Math.abs(current.x - start.x),
|
|
height: Math.abs(current.y - start.y),
|
|
source: "manual",
|
|
});
|
|
}
|
|
: undefined
|
|
}
|
|
onPointerUp={
|
|
status === "manual"
|
|
? (event) => {
|
|
const start = dragStartRef.current;
|
|
dragStartRef.current = null;
|
|
if (!start) return;
|
|
const current = pointerPosition(event);
|
|
const box = {
|
|
id: manualDraft?.id || `manual-${Date.now()}`,
|
|
x: Math.min(start.x, current.x),
|
|
y: Math.min(start.y, current.y),
|
|
width: Math.abs(current.x - start.x),
|
|
height: Math.abs(current.y - start.y),
|
|
source: "manual" as const,
|
|
};
|
|
if (box.width >= Math.max(18, imageWidth * 0.025) && box.height >= Math.max(18, imageHeight * 0.025)) onManualComplete(box);
|
|
else onManualDraftChange(null);
|
|
}
|
|
: undefined
|
|
}
|
|
onPointerCancel={
|
|
status === "manual"
|
|
? () => {
|
|
dragStartRef.current = null;
|
|
onManualDraftChange(null);
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full" viewBox={`0 0 ${imageWidth} ${imageHeight}`} preserveAspectRatio="none">
|
|
<defs>
|
|
<mask id={`emotion-face-mask-${node.id}`}>
|
|
<rect width={imageWidth} height={imageHeight} fill="white" />
|
|
{faces.map((face) => (
|
|
<rect key={face.id} x={face.x} y={face.y} width={face.width} height={face.height} rx={Math.min(face.width, face.height) * 0.16} fill="black" />
|
|
))}
|
|
{manualDraft ? <rect x={manualDraft.x} y={manualDraft.y} width={manualDraft.width} height={manualDraft.height} rx={Math.min(manualDraft.width, manualDraft.height) * 0.12} fill="black" /> : null}
|
|
</mask>
|
|
</defs>
|
|
<rect width={imageWidth} height={imageHeight} fill="rgba(0,0,0,.38)" mask={`url(#emotion-face-mask-${node.id})`} />
|
|
</svg>
|
|
{faces.map((face) => {
|
|
const selected = faceIsSelected(face);
|
|
const active = selected?.id === activeCharacterId;
|
|
return (
|
|
<motion.button
|
|
key={face.id}
|
|
type="button"
|
|
aria-label={selected ? `选择${selected.name}` : "选择此人脸"}
|
|
className={`absolute rounded-[var(--r-md)] border-2 ${interactive ? "pointer-events-auto" : "pointer-events-none"}`}
|
|
style={{
|
|
left: `${(face.x / imageWidth) * 100}%`,
|
|
top: `${(face.y / imageHeight) * 100}%`,
|
|
width: `${(face.width / imageWidth) * 100}%`,
|
|
height: `${(face.height / imageHeight) * 100}%`,
|
|
borderColor: active ? theme.accent.primary : "rgba(255,255,255,.94)",
|
|
boxShadow: active ? `0 0 0 3px ${theme.accent.primarySoft}, 0 8px 24px rgba(0,0,0,.24)` : "0 8px 20px rgba(0,0,0,.18)",
|
|
}}
|
|
whileHover={{ scale: 1.025 }}
|
|
whileTap={{ scale: 0.985 }}
|
|
transition={aceternityMotion.spring.dock}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onFaceSelect(face);
|
|
}}
|
|
>
|
|
{selected ? (
|
|
<span
|
|
className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold"
|
|
style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)", color: active ? theme.accent.onPrimary : "#ffffff" }}
|
|
>
|
|
{selected.name}
|
|
</span>
|
|
) : null}
|
|
</motion.button>
|
|
);
|
|
})}
|
|
{manualDraft ? (
|
|
<div
|
|
className="pointer-events-none absolute rounded-[var(--r-md)] border-2 border-dashed border-white"
|
|
style={{
|
|
left: `${(manualDraft.x / imageWidth) * 100}%`,
|
|
top: `${(manualDraft.y / imageHeight) * 100}%`,
|
|
width: `${(manualDraft.width / imageWidth) * 100}%`,
|
|
height: `${(manualDraft.height / imageHeight) * 100}%`,
|
|
boxShadow: "0 0 0 3px rgba(255,255,255,.16)",
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectionToolbar({
|
|
node,
|
|
viewport,
|
|
containerRef,
|
|
status,
|
|
faceCount,
|
|
error,
|
|
onManualSelect,
|
|
onClose,
|
|
}: {
|
|
node: CanvasNodeData;
|
|
viewport: ViewportTransform;
|
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
status: WorkspaceStatus;
|
|
faceCount: number;
|
|
error: string;
|
|
onManualSelect: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const theme = canvasThemes[useActiveTheme()];
|
|
const reducedMotion = useReducedMotion();
|
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
useScreenAnchor(toolbarRef, node, viewport, containerRef, (next, container) => toolbarScreenRect(node, next, container, toolbarRef.current));
|
|
if (status === "editing" || status === "generating") return null;
|
|
const label = status === "detecting" ? "正在识别人脸" : status === "manual" ? "拖动鼠标框选需要调节的人脸" : status === "selecting" ? `识别到 ${faceCount} 张人脸,请选择人物` : error || "请选择人物";
|
|
return (
|
|
<SpotlightSurface
|
|
ref={toolbarRef}
|
|
data-canvas-no-zoom
|
|
spotlightColor={theme.toolbar.itemHover}
|
|
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -5, scale: 0.96 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
|
|
className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] flex h-12 w-[420px] max-w-[calc(100%_-_24px)] items-center rounded-[var(--dock-radius-tight)] border px-2 backdrop-blur-2xl"
|
|
style={{ left: 12, top: 76, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 22px 60px ${theme.spatial.shadow}` }}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex size-full items-center">
|
|
<button type="button" aria-label="关闭情绪调节" className="grid size-8 shrink-0 place-items-center rounded-full hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}>
|
|
<X className="size-4" />
|
|
</button>
|
|
<span className="mx-2 h-5 w-px" style={{ background: theme.toolbar.border }} />
|
|
<span className="grid size-8 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover }}>
|
|
{status === "detecting" ? <LoaderCircle className="size-4 animate-spin" /> : <ScanFace className="size-4" />}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate px-2 text-[var(--fs-label)] font-medium leading-none">{label}</span>
|
|
{status !== "detecting" ? (
|
|
<button
|
|
type="button"
|
|
className="flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-label)] font-medium leading-none transition hover:bg-black/5 dark:hover:bg-white/10"
|
|
onClick={onManualSelect}
|
|
>
|
|
<SquareDashedMousePointer className="size-3.5" />
|
|
手动框选
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</SpotlightSurface>
|
|
);
|
|
}
|
|
|
|
function useScreenAnchor(
|
|
ref: RefObject<HTMLElement | null>,
|
|
node: CanvasNodeData,
|
|
viewport: ViewportTransform,
|
|
containerRef: RefObject<HTMLDivElement | null>,
|
|
resolve: (viewport: ViewportTransform, container: HTMLDivElement) => { left: number; top: number; width?: number; height?: number },
|
|
) {
|
|
const resolveRef = useRef(resolve);
|
|
resolveRef.current = resolve;
|
|
useLayoutEffect(() => {
|
|
const element = ref.current;
|
|
const container = containerRef.current;
|
|
if (!element || !container) return;
|
|
const update = (next: ViewportTransform) => {
|
|
const rect = resolveRef.current(next, container);
|
|
element.style.left = `${rect.left}px`;
|
|
element.style.top = `${rect.top}px`;
|
|
if (typeof rect.width === "number") element.style.width = `${rect.width}px`;
|
|
if (typeof rect.height === "number") element.style.height = `${rect.height}px`;
|
|
};
|
|
update(viewport);
|
|
const observer = new ResizeObserver(() => update(viewport));
|
|
observer.observe(container);
|
|
observer.observe(element);
|
|
const unsubscribe = subscribeCanvasViewportPreview(container, update);
|
|
return () => {
|
|
observer.disconnect();
|
|
unsubscribe();
|
|
};
|
|
}, [containerRef, node.height, node.id, node.metadata?.freeResize, node.position.x, node.position.y, node.width, ref, viewport]);
|
|
}
|
|
|
|
function imageScreenRect(node: CanvasNodeData, viewport: ViewportTransform, imageWidth: number, imageHeight: number) {
|
|
const nodeWidth = node.width * viewport.k;
|
|
const nodeHeight = node.height * viewport.k;
|
|
const nodeLeft = viewport.x + node.position.x * viewport.k;
|
|
const nodeTop = viewport.y + node.position.y * viewport.k;
|
|
if (node.metadata?.freeResize || !imageWidth || !imageHeight) return { left: nodeLeft, top: nodeTop, width: nodeWidth, height: nodeHeight };
|
|
const scale = Math.min(nodeWidth / imageWidth, nodeHeight / imageHeight);
|
|
const width = imageWidth * scale;
|
|
const height = imageHeight * scale;
|
|
return { left: nodeLeft + (nodeWidth - width) / 2, top: nodeTop + (nodeHeight - height) / 2, width, height };
|
|
}
|
|
|
|
function toolbarScreenRect(node: CanvasNodeData, viewport: ViewportTransform, container: HTMLDivElement, element: HTMLElement | null) {
|
|
const width = Math.min(element?.offsetWidth || 420, Math.max(360, container.clientWidth - 24));
|
|
const height = element?.offsetHeight || 48;
|
|
const nodeRect = imageScreenRect(node, viewport, node.metadata?.naturalWidth || node.width, node.metadata?.naturalHeight || node.height);
|
|
const viewportLeft = container.scrollLeft;
|
|
const viewportTop = container.scrollTop;
|
|
const left = clamp(nodeRect.left + nodeRect.width / 2 - width / 2, viewportLeft + 12, Math.max(viewportLeft + 12, viewportLeft + container.clientWidth - width - 12));
|
|
const above = nodeRect.top - height - 10;
|
|
const top = above >= viewportTop + 72 ? above : clamp(nodeRect.top + nodeRect.height + 10, viewportTop + 72, Math.max(viewportTop + 72, viewportTop + container.clientHeight - height - 12));
|
|
return { left, top };
|
|
}
|
|
|
|
function sameFace(left: CanvasFaceBox, right: CanvasFaceBox) {
|
|
return left.id === right.id || (Math.abs(left.x - right.x) < 1 && Math.abs(left.y - right.y) < 1 && Math.abs(left.width - right.width) < 1 && Math.abs(left.height - right.height) < 1);
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|