388 lines
9.8 KiB
TypeScript
388 lines
9.8 KiB
TypeScript
import {
|
|
buildGraphSnapshot,
|
|
resolveNodeImageUrl,
|
|
resolveRenderPreviewInputFromGraph,
|
|
type CanvasGraphEdgeLike,
|
|
type CanvasGraphNodeLike,
|
|
type CanvasGraphSnapshot,
|
|
} from "@/lib/canvas-render-preview";
|
|
|
|
export type MixerBlendMode = "normal" | "multiply" | "screen" | "overlay";
|
|
|
|
export type MixerPreviewStatus = "empty" | "partial" | "ready" | "error";
|
|
|
|
export type MixerPreviewError = "duplicate-handle-edge";
|
|
|
|
export type MixerPreviewState = {
|
|
status: MixerPreviewStatus;
|
|
baseUrl?: string;
|
|
overlayUrl?: string;
|
|
blendMode: MixerBlendMode;
|
|
opacity: number;
|
|
overlayX: number;
|
|
overlayY: number;
|
|
overlayWidth: number;
|
|
overlayHeight: number;
|
|
cropLeft: number;
|
|
cropTop: number;
|
|
cropRight: number;
|
|
cropBottom: number;
|
|
error?: MixerPreviewError;
|
|
};
|
|
|
|
const MIXER_SOURCE_NODE_TYPES = new Set(["image", "asset", "ai-image", "render"]);
|
|
const MIXER_BLEND_MODES = new Set<MixerBlendMode>([
|
|
"normal",
|
|
"multiply",
|
|
"screen",
|
|
"overlay",
|
|
]);
|
|
const DEFAULT_BLEND_MODE: MixerBlendMode = "normal";
|
|
const DEFAULT_OPACITY = 100;
|
|
const MIN_OPACITY = 0;
|
|
const MAX_OPACITY = 100;
|
|
const DEFAULT_OVERLAY_X = 0;
|
|
const DEFAULT_OVERLAY_Y = 0;
|
|
const DEFAULT_OVERLAY_WIDTH = 1;
|
|
const DEFAULT_OVERLAY_HEIGHT = 1;
|
|
const DEFAULT_CROP_LEFT = 0;
|
|
const DEFAULT_CROP_TOP = 0;
|
|
const DEFAULT_CROP_RIGHT = 0;
|
|
const DEFAULT_CROP_BOTTOM = 0;
|
|
const MIN_OVERLAY_POSITION = 0;
|
|
const MAX_OVERLAY_POSITION = 1;
|
|
const MIN_OVERLAY_SIZE = 0.1;
|
|
const MAX_OVERLAY_SIZE = 1;
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function parseNumeric(value: unknown): number | null {
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeOpacity(value: unknown): number {
|
|
const parsed = parseNumeric(value);
|
|
if (parsed === null) {
|
|
return DEFAULT_OPACITY;
|
|
}
|
|
|
|
return clamp(parsed, MIN_OPACITY, MAX_OPACITY);
|
|
}
|
|
|
|
function normalizeOverlayNumber(value: unknown, fallback: number): number {
|
|
const parsed = parseNumeric(value);
|
|
if (parsed === null) {
|
|
return fallback;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeUnitRect(args: {
|
|
x: unknown;
|
|
y: unknown;
|
|
width: unknown;
|
|
height: unknown;
|
|
defaults: { x: number; y: number; width: number; height: number };
|
|
}): { x: number; y: number; width: number; height: number } {
|
|
const x = clamp(
|
|
normalizeOverlayNumber(args.x, args.defaults.x),
|
|
MIN_OVERLAY_POSITION,
|
|
MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE,
|
|
);
|
|
const y = clamp(
|
|
normalizeOverlayNumber(args.y, args.defaults.y),
|
|
MIN_OVERLAY_POSITION,
|
|
MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE,
|
|
);
|
|
const width = clamp(
|
|
normalizeOverlayNumber(args.width, args.defaults.width),
|
|
MIN_OVERLAY_SIZE,
|
|
Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - x),
|
|
);
|
|
const height = clamp(
|
|
normalizeOverlayNumber(args.height, args.defaults.height),
|
|
MIN_OVERLAY_SIZE,
|
|
Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - y),
|
|
);
|
|
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
function normalizeOverlayRect(record: Record<string, unknown>): Pick<
|
|
MixerPreviewState,
|
|
"overlayX" | "overlayY" | "overlayWidth" | "overlayHeight"
|
|
> {
|
|
const hasLegacyOffset = record.offsetX !== undefined || record.offsetY !== undefined;
|
|
const hasOverlayRectField =
|
|
record.overlayX !== undefined ||
|
|
record.overlayY !== undefined ||
|
|
record.overlayWidth !== undefined ||
|
|
record.overlayHeight !== undefined;
|
|
|
|
if (hasLegacyOffset && !hasOverlayRectField) {
|
|
return {
|
|
overlayX: DEFAULT_OVERLAY_X,
|
|
overlayY: DEFAULT_OVERLAY_Y,
|
|
overlayWidth: DEFAULT_OVERLAY_WIDTH,
|
|
overlayHeight: DEFAULT_OVERLAY_HEIGHT,
|
|
};
|
|
}
|
|
|
|
const normalized = normalizeUnitRect({
|
|
x: record.overlayX,
|
|
y: record.overlayY,
|
|
width: record.overlayWidth,
|
|
height: record.overlayHeight,
|
|
defaults: {
|
|
x: DEFAULT_OVERLAY_X,
|
|
y: DEFAULT_OVERLAY_Y,
|
|
width: DEFAULT_OVERLAY_WIDTH,
|
|
height: DEFAULT_OVERLAY_HEIGHT,
|
|
},
|
|
});
|
|
|
|
return {
|
|
overlayX: normalized.x,
|
|
overlayY: normalized.y,
|
|
overlayWidth: normalized.width,
|
|
overlayHeight: normalized.height,
|
|
};
|
|
}
|
|
|
|
function normalizeCropEdges(record: Record<string, unknown>): Pick<
|
|
MixerPreviewState,
|
|
"cropLeft" | "cropTop" | "cropRight" | "cropBottom"
|
|
> {
|
|
const hasCropField =
|
|
record.cropLeft !== undefined ||
|
|
record.cropTop !== undefined ||
|
|
record.cropRight !== undefined ||
|
|
record.cropBottom !== undefined;
|
|
const hasLegacyContentRectField =
|
|
record.contentX !== undefined ||
|
|
record.contentY !== undefined ||
|
|
record.contentWidth !== undefined ||
|
|
record.contentHeight !== undefined;
|
|
|
|
if (!hasCropField && hasLegacyContentRectField) {
|
|
const legacyRect = normalizeUnitRect({
|
|
x: record.contentX,
|
|
y: record.contentY,
|
|
width: record.contentWidth,
|
|
height: record.contentHeight,
|
|
defaults: {
|
|
x: 0,
|
|
y: 0,
|
|
width: 1,
|
|
height: 1,
|
|
},
|
|
});
|
|
|
|
return {
|
|
cropLeft: legacyRect.x,
|
|
cropTop: legacyRect.y,
|
|
cropRight: 1 - (legacyRect.x + legacyRect.width),
|
|
cropBottom: 1 - (legacyRect.y + legacyRect.height),
|
|
};
|
|
}
|
|
|
|
const cropLeft = clamp(
|
|
normalizeOverlayNumber(record.cropLeft, DEFAULT_CROP_LEFT),
|
|
0,
|
|
1 - MIN_OVERLAY_SIZE,
|
|
);
|
|
const cropTop = clamp(
|
|
normalizeOverlayNumber(record.cropTop, DEFAULT_CROP_TOP),
|
|
0,
|
|
1 - MIN_OVERLAY_SIZE,
|
|
);
|
|
const cropRight = clamp(
|
|
normalizeOverlayNumber(record.cropRight, DEFAULT_CROP_RIGHT),
|
|
0,
|
|
1 - cropLeft - MIN_OVERLAY_SIZE,
|
|
);
|
|
const cropBottom = clamp(
|
|
normalizeOverlayNumber(record.cropBottom, DEFAULT_CROP_BOTTOM),
|
|
0,
|
|
1 - cropTop - MIN_OVERLAY_SIZE,
|
|
);
|
|
|
|
return {
|
|
cropLeft,
|
|
cropTop,
|
|
cropRight,
|
|
cropBottom,
|
|
};
|
|
}
|
|
|
|
export function normalizeMixerPreviewData(data: unknown): Pick<
|
|
MixerPreviewState,
|
|
| "blendMode"
|
|
| "opacity"
|
|
| "overlayX"
|
|
| "overlayY"
|
|
| "overlayWidth"
|
|
| "overlayHeight"
|
|
| "cropLeft"
|
|
| "cropTop"
|
|
| "cropRight"
|
|
| "cropBottom"
|
|
> {
|
|
const record = (data ?? {}) as Record<string, unknown>;
|
|
const blendMode = MIXER_BLEND_MODES.has(record.blendMode as MixerBlendMode)
|
|
? (record.blendMode as MixerBlendMode)
|
|
: DEFAULT_BLEND_MODE;
|
|
|
|
return {
|
|
blendMode,
|
|
opacity: normalizeOpacity(record.opacity),
|
|
...normalizeOverlayRect(record),
|
|
...normalizeCropEdges(record),
|
|
};
|
|
}
|
|
|
|
function resolveHandleEdge(args: {
|
|
incomingEdges: readonly CanvasGraphEdgeLike[];
|
|
handle: "base" | "overlay";
|
|
}): { edge: CanvasGraphEdgeLike | null; duplicate: boolean } {
|
|
const edges = args.incomingEdges.filter((edge) => {
|
|
if (args.handle === "base") {
|
|
return edge.targetHandle === "base" || edge.targetHandle == null || edge.targetHandle === "";
|
|
}
|
|
|
|
return edge.targetHandle === "overlay";
|
|
});
|
|
|
|
if (edges.length > 1) {
|
|
return { edge: null, duplicate: true };
|
|
}
|
|
|
|
return { edge: edges[0] ?? null, duplicate: false };
|
|
}
|
|
|
|
function resolveSourceUrlFromNode(args: {
|
|
sourceNode: CanvasGraphNodeLike;
|
|
graph: CanvasGraphSnapshot;
|
|
}): string | undefined {
|
|
if (!MIXER_SOURCE_NODE_TYPES.has(args.sourceNode.type)) {
|
|
return undefined;
|
|
}
|
|
|
|
if (args.sourceNode.type === "render") {
|
|
const preview = resolveRenderPreviewInputFromGraph({
|
|
nodeId: args.sourceNode.id,
|
|
graph: args.graph,
|
|
});
|
|
if (preview.sourceComposition) {
|
|
return undefined;
|
|
}
|
|
if (preview.sourceUrl) {
|
|
return preview.sourceUrl;
|
|
}
|
|
|
|
const renderData = (args.sourceNode.data ?? {}) as Record<string, unknown>;
|
|
const renderOutputUrl =
|
|
typeof renderData.lastUploadUrl === "string" && renderData.lastUploadUrl.length > 0
|
|
? renderData.lastUploadUrl
|
|
: undefined;
|
|
if (renderOutputUrl) {
|
|
return renderOutputUrl;
|
|
}
|
|
|
|
const directRenderUrl = resolveNodeImageUrl(args.sourceNode.data);
|
|
if (directRenderUrl) {
|
|
return directRenderUrl;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
return resolveNodeImageUrl(args.sourceNode.data) ?? undefined;
|
|
}
|
|
|
|
function resolveSourceUrlFromEdge(args: {
|
|
edge: CanvasGraphEdgeLike | null;
|
|
graph: CanvasGraphSnapshot;
|
|
}): string | undefined {
|
|
if (!args.edge) {
|
|
return undefined;
|
|
}
|
|
|
|
const sourceNode = args.graph.nodesById.get(args.edge.source);
|
|
if (!sourceNode) {
|
|
return undefined;
|
|
}
|
|
|
|
return resolveSourceUrlFromNode({ sourceNode, graph: args.graph });
|
|
}
|
|
|
|
export function resolveMixerPreviewFromGraph(args: {
|
|
nodeId: string;
|
|
graph: CanvasGraphSnapshot;
|
|
}): MixerPreviewState {
|
|
const node = args.graph.nodesById.get(args.nodeId);
|
|
const normalized = normalizeMixerPreviewData(node?.data);
|
|
const incomingEdges = args.graph.incomingEdgesByTarget.get(args.nodeId) ?? [];
|
|
const base = resolveHandleEdge({ incomingEdges, handle: "base" });
|
|
const overlay = resolveHandleEdge({ incomingEdges, handle: "overlay" });
|
|
|
|
if (base.duplicate || overlay.duplicate) {
|
|
return {
|
|
status: "error",
|
|
baseUrl: undefined,
|
|
overlayUrl: undefined,
|
|
...normalized,
|
|
error: "duplicate-handle-edge",
|
|
};
|
|
}
|
|
|
|
const baseUrl = resolveSourceUrlFromEdge({ edge: base.edge, graph: args.graph });
|
|
const overlayUrl = resolveSourceUrlFromEdge({ edge: overlay.edge, graph: args.graph });
|
|
|
|
if (baseUrl && overlayUrl) {
|
|
return {
|
|
status: "ready",
|
|
...normalized,
|
|
baseUrl,
|
|
overlayUrl,
|
|
};
|
|
}
|
|
|
|
if (baseUrl || overlayUrl) {
|
|
return {
|
|
status: "partial",
|
|
...normalized,
|
|
baseUrl,
|
|
overlayUrl,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "empty",
|
|
...normalized,
|
|
};
|
|
}
|
|
|
|
export function resolveMixerPreview(args: {
|
|
nodeId: string;
|
|
nodes: readonly CanvasGraphNodeLike[];
|
|
edges: readonly CanvasGraphEdgeLike[];
|
|
}): MixerPreviewState {
|
|
return resolveMixerPreviewFromGraph({
|
|
nodeId: args.nodeId,
|
|
graph: buildGraphSnapshot(args.nodes, args.edges),
|
|
});
|
|
}
|