feat(canvas): add mixer drag-resize and mixer->render bake
This commit is contained in:
@@ -15,10 +15,25 @@ export type RenderPreviewGraphEdge = {
|
||||
};
|
||||
|
||||
export type RenderPreviewInput = {
|
||||
sourceUrl: string;
|
||||
sourceUrl: string | null;
|
||||
sourceComposition?: RenderPreviewSourceComposition;
|
||||
steps: PipelineStep[];
|
||||
};
|
||||
|
||||
export type MixerBlendMode = "normal" | "multiply" | "screen" | "overlay";
|
||||
|
||||
export type RenderPreviewSourceComposition = {
|
||||
kind: "mixer";
|
||||
baseUrl: string;
|
||||
overlayUrl: string;
|
||||
blendMode: MixerBlendMode;
|
||||
opacity: number;
|
||||
overlayX: number;
|
||||
overlayY: number;
|
||||
overlayWidth: number;
|
||||
overlayHeight: number;
|
||||
};
|
||||
|
||||
export type CanvasGraphNodeLike = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -38,6 +53,8 @@ export type CanvasGraphSnapshot = {
|
||||
incomingEdgesByTarget: ReadonlyMap<string, readonly CanvasGraphEdgeLike[]>;
|
||||
};
|
||||
|
||||
type RenderPreviewResolvedInput = RenderPreviewInput;
|
||||
|
||||
export type CanvasGraphNodeDataOverrides = ReadonlyMap<string, unknown>;
|
||||
|
||||
export function shouldFastPathPreviewPipeline(
|
||||
@@ -129,6 +146,110 @@ export const RENDER_PREVIEW_PIPELINE_TYPES = new Set([
|
||||
"detail-adjust",
|
||||
]);
|
||||
|
||||
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 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 normalizeMixerCompositionRect(data: Record<string, unknown>): Pick<
|
||||
RenderPreviewSourceComposition,
|
||||
"overlayX" | "overlayY" | "overlayWidth" | "overlayHeight"
|
||||
> {
|
||||
const hasLegacyOffset = data.offsetX !== undefined || data.offsetY !== undefined;
|
||||
const hasOverlayRectField =
|
||||
data.overlayX !== undefined ||
|
||||
data.overlayY !== undefined ||
|
||||
data.overlayWidth !== undefined ||
|
||||
data.overlayHeight !== undefined;
|
||||
|
||||
if (hasLegacyOffset && !hasOverlayRectField) {
|
||||
return {
|
||||
overlayX: DEFAULT_OVERLAY_X,
|
||||
overlayY: DEFAULT_OVERLAY_Y,
|
||||
overlayWidth: DEFAULT_OVERLAY_WIDTH,
|
||||
overlayHeight: DEFAULT_OVERLAY_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
const overlayX = clamp(
|
||||
normalizeOverlayNumber(data.overlayX, DEFAULT_OVERLAY_X),
|
||||
MIN_OVERLAY_POSITION,
|
||||
MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE,
|
||||
);
|
||||
const overlayY = clamp(
|
||||
normalizeOverlayNumber(data.overlayY, DEFAULT_OVERLAY_Y),
|
||||
MIN_OVERLAY_POSITION,
|
||||
MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE,
|
||||
);
|
||||
const overlayWidth = clamp(
|
||||
normalizeOverlayNumber(data.overlayWidth, DEFAULT_OVERLAY_WIDTH),
|
||||
MIN_OVERLAY_SIZE,
|
||||
Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayX),
|
||||
);
|
||||
const overlayHeight = clamp(
|
||||
normalizeOverlayNumber(data.overlayHeight, DEFAULT_OVERLAY_HEIGHT),
|
||||
MIN_OVERLAY_SIZE,
|
||||
Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayY),
|
||||
);
|
||||
|
||||
return {
|
||||
overlayX,
|
||||
overlayY,
|
||||
overlayWidth,
|
||||
overlayHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRenderFingerprint(data: unknown): {
|
||||
resolution: RenderResolutionOption;
|
||||
customWidth?: number;
|
||||
@@ -163,15 +284,19 @@ export function resolveRenderFingerprint(data: unknown): {
|
||||
|
||||
export function resolveRenderPipelineHash(args: {
|
||||
sourceUrl: string | null;
|
||||
sourceComposition?: RenderPreviewSourceComposition;
|
||||
steps: PipelineStep[];
|
||||
data: unknown;
|
||||
}): string | null {
|
||||
if (!args.sourceUrl) {
|
||||
if (!args.sourceUrl && !args.sourceComposition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hashPipeline(
|
||||
{ sourceUrl: args.sourceUrl, render: resolveRenderFingerprint(args.data) },
|
||||
{
|
||||
source: args.sourceComposition ?? args.sourceUrl,
|
||||
render: resolveRenderFingerprint(args.data),
|
||||
},
|
||||
args.steps,
|
||||
);
|
||||
}
|
||||
@@ -212,6 +337,115 @@ function resolveSourceNodeUrl(node: CanvasGraphNodeLike): string | null {
|
||||
return resolveNodeImageUrl(node.data);
|
||||
}
|
||||
|
||||
function resolveRenderOutputUrl(node: CanvasGraphNodeLike): string | null {
|
||||
const data = (node.data ?? {}) as Record<string, unknown>;
|
||||
|
||||
const lastUploadUrl =
|
||||
typeof data.lastUploadUrl === "string" && data.lastUploadUrl.length > 0
|
||||
? data.lastUploadUrl
|
||||
: null;
|
||||
if (lastUploadUrl) {
|
||||
return lastUploadUrl;
|
||||
}
|
||||
|
||||
return resolveNodeImageUrl(node.data);
|
||||
}
|
||||
|
||||
function resolveMixerHandleEdge(args: {
|
||||
incomingEdges: readonly CanvasGraphEdgeLike[];
|
||||
handle: "base" | "overlay";
|
||||
}): CanvasGraphEdgeLike | null {
|
||||
const filtered = args.incomingEdges.filter((edge) => {
|
||||
if (args.handle === "base") {
|
||||
return edge.targetHandle === "base" || edge.targetHandle == null || edge.targetHandle === "";
|
||||
}
|
||||
|
||||
return edge.targetHandle === "overlay";
|
||||
});
|
||||
|
||||
if (filtered.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filtered[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveMixerSourceUrlFromNode(args: {
|
||||
node: CanvasGraphNodeLike;
|
||||
graph: CanvasGraphSnapshot;
|
||||
}): string | null {
|
||||
if (!MIXER_SOURCE_NODE_TYPES.has(args.node.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.node.type === "render") {
|
||||
const directRenderUrl = resolveRenderOutputUrl(args.node);
|
||||
if (directRenderUrl) {
|
||||
return directRenderUrl;
|
||||
}
|
||||
|
||||
const preview = resolveRenderPreviewInputFromGraph({
|
||||
nodeId: args.node.id,
|
||||
graph: args.graph,
|
||||
});
|
||||
if (preview.sourceComposition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preview.sourceUrl;
|
||||
}
|
||||
|
||||
return resolveNodeImageUrl(args.node.data);
|
||||
}
|
||||
|
||||
function resolveMixerSourceUrlFromEdge(args: {
|
||||
edge: CanvasGraphEdgeLike | null;
|
||||
graph: CanvasGraphSnapshot;
|
||||
}): string | null {
|
||||
if (!args.edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceNode = args.graph.nodesById.get(args.edge.source);
|
||||
if (!sourceNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveMixerSourceUrlFromNode({
|
||||
node: sourceNode,
|
||||
graph: args.graph,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRenderMixerCompositionFromGraph(args: {
|
||||
node: CanvasGraphNodeLike;
|
||||
graph: CanvasGraphSnapshot;
|
||||
}): RenderPreviewSourceComposition | null {
|
||||
const incomingEdges = args.graph.incomingEdgesByTarget.get(args.node.id) ?? [];
|
||||
const baseEdge = resolveMixerHandleEdge({ incomingEdges, handle: "base" });
|
||||
const overlayEdge = resolveMixerHandleEdge({ incomingEdges, handle: "overlay" });
|
||||
const baseUrl = resolveMixerSourceUrlFromEdge({ edge: baseEdge, graph: args.graph });
|
||||
const overlayUrl = resolveMixerSourceUrlFromEdge({ edge: overlayEdge, graph: args.graph });
|
||||
|
||||
if (!baseUrl || !overlayUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (args.node.data ?? {}) as Record<string, unknown>;
|
||||
const blendMode = MIXER_BLEND_MODES.has(data.blendMode as MixerBlendMode)
|
||||
? (data.blendMode as MixerBlendMode)
|
||||
: DEFAULT_BLEND_MODE;
|
||||
|
||||
return {
|
||||
kind: "mixer",
|
||||
baseUrl,
|
||||
overlayUrl,
|
||||
blendMode,
|
||||
opacity: normalizeOpacity(data.opacity),
|
||||
...normalizeMixerCompositionRect(data),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphSnapshot(
|
||||
nodes: readonly CanvasGraphNodeLike[],
|
||||
edges: readonly CanvasGraphEdgeLike[],
|
||||
@@ -384,7 +618,32 @@ export function findSourceNodeFromGraph(
|
||||
export function resolveRenderPreviewInputFromGraph(args: {
|
||||
nodeId: string;
|
||||
graph: CanvasGraphSnapshot;
|
||||
}): { sourceUrl: string | null; steps: PipelineStep[] } {
|
||||
}): RenderPreviewResolvedInput {
|
||||
const renderIncoming = getSortedIncomingEdge(
|
||||
args.graph.incomingEdgesByTarget.get(args.nodeId),
|
||||
);
|
||||
const renderInputNode = renderIncoming
|
||||
? args.graph.nodesById.get(renderIncoming.source)
|
||||
: null;
|
||||
|
||||
if (renderInputNode?.type === "mixer") {
|
||||
const sourceComposition = resolveRenderMixerCompositionFromGraph({
|
||||
node: renderInputNode,
|
||||
graph: args.graph,
|
||||
});
|
||||
|
||||
const steps = collectPipelineFromGraph(args.graph, {
|
||||
nodeId: args.nodeId,
|
||||
isPipelineNode: (node) => RENDER_PREVIEW_PIPELINE_TYPES.has(node.type ?? ""),
|
||||
});
|
||||
|
||||
return {
|
||||
sourceUrl: null,
|
||||
sourceComposition: sourceComposition ?? undefined,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceUrl = getSourceImageFromGraph(args.graph, {
|
||||
nodeId: args.nodeId,
|
||||
isSourceNode: (node) => SOURCE_NODE_TYPES.has(node.type ?? ""),
|
||||
@@ -406,7 +665,7 @@ export function resolveRenderPreviewInput(args: {
|
||||
nodeId: string;
|
||||
nodes: readonly RenderPreviewGraphNode[];
|
||||
edges: readonly RenderPreviewGraphEdge[];
|
||||
}): { sourceUrl: string | null; steps: PipelineStep[] } {
|
||||
}): RenderPreviewResolvedInput {
|
||||
return resolveRenderPreviewInputFromGraph({
|
||||
nodeId: args.nodeId,
|
||||
graph: buildGraphSnapshot(args.nodes, args.edges),
|
||||
|
||||
Reference in New Issue
Block a user