7 changed files with 650 additions and 117 deletions
@ -0,0 +1,102 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { remapNodeDataIds } from "../remapWorkflowIds"; |
|||
|
|||
describe("remapNodeDataIds", () => { |
|||
const nodeMap = new Map<string, string>([ |
|||
["imageInput-1", "imageInput-10"], |
|||
["video-1", "video-20"], |
|||
["video-2", "video-21"], |
|||
]); |
|||
const edgeMap = new Map<string, string>([ |
|||
["edge-video-1-target-default-default", "edge-video-20-target-default-default"], |
|||
["edge-video-2-target-default-default", "edge-video-21-target-default-default"], |
|||
]); |
|||
|
|||
it("remaps referenceSubjectList[].id via the node mapping", () => { |
|||
const data = { |
|||
prompt: "p", |
|||
referenceSubjectList: [ |
|||
{ id: "imageInput-1", type: "image", url: "u", name: "n" }, |
|||
{ id: "external-99", type: "image", url: "u2", name: "n2" }, |
|||
], |
|||
}; |
|||
const out = remapNodeDataIds("nanoBanana", data, nodeMap, edgeMap) as typeof data; |
|||
expect(out.referenceSubjectList[0].id).toBe("imageInput-10"); |
|||
// Unmapped ids are preserved untouched.
|
|||
expect(out.referenceSubjectList[1].id).toBe("external-99"); |
|||
}); |
|||
|
|||
it("remaps derivedImage / derivedVideo / derivedMedia sourceNodeId", () => { |
|||
const image = remapNodeDataIds( |
|||
"imageInput", |
|||
{ derivedImage: { sourceNodeId: "imageInput-1", operation: "crop" } }, |
|||
nodeMap, |
|||
edgeMap |
|||
) as { derivedImage: { sourceNodeId: string } }; |
|||
expect(image.derivedImage.sourceNodeId).toBe("imageInput-10"); |
|||
|
|||
const media = remapNodeDataIds( |
|||
"videoInput", |
|||
{ derivedMedia: { sourceNodeId: "video-1", operation: "videoFrame" } }, |
|||
nodeMap, |
|||
edgeMap |
|||
) as { derivedMedia: { sourceNodeId: string } }; |
|||
expect(media.derivedMedia.sourceNodeId).toBe("video-20"); |
|||
}); |
|||
|
|||
it("remaps videoStitch clips (node + edge ids) and clipOrder (edge ids)", () => { |
|||
const data = { |
|||
clips: [ |
|||
{ |
|||
edgeId: "edge-video-1-target-default-default", |
|||
sourceNodeId: "video-1", |
|||
duration: 1, |
|||
handleId: "video-0", |
|||
}, |
|||
{ |
|||
edgeId: "edge-video-2-target-default-default", |
|||
sourceNodeId: "video-2", |
|||
duration: 2, |
|||
handleId: "video-1", |
|||
}, |
|||
], |
|||
clipOrder: [ |
|||
"edge-video-1-target-default-default", |
|||
"edge-video-2-target-default-default", |
|||
], |
|||
}; |
|||
const out = remapNodeDataIds("videoStitch", data, nodeMap, edgeMap) as typeof data; |
|||
expect(out.clips[0].sourceNodeId).toBe("video-20"); |
|||
expect(out.clips[0].edgeId).toBe("edge-video-20-target-default-default"); |
|||
expect(out.clips[1].sourceNodeId).toBe("video-21"); |
|||
expect(out.clipOrder).toEqual([ |
|||
"edge-video-20-target-default-default", |
|||
"edge-video-21-target-default-default", |
|||
]); |
|||
}); |
|||
|
|||
it("remaps easeCurve inheritedFrom", () => { |
|||
const out = remapNodeDataIds( |
|||
"easeCurve", |
|||
{ inheritedFrom: "video-1" }, |
|||
nodeMap, |
|||
edgeMap |
|||
) as { inheritedFrom: string }; |
|||
expect(out.inheritedFrom).toBe("video-20"); |
|||
}); |
|||
|
|||
it("does not mutate the input object", () => { |
|||
const data = { |
|||
referenceSubjectList: [{ id: "imageInput-1", type: "image", url: "u", name: "n" }], |
|||
}; |
|||
remapNodeDataIds("nanoBanana", data, nodeMap, edgeMap); |
|||
expect(data.referenceSubjectList[0].id).toBe("imageInput-1"); |
|||
}); |
|||
|
|||
it("returns primitives / empty data unchanged", () => { |
|||
expect(remapNodeDataIds("prompt", { prompt: "hi" }, nodeMap, edgeMap)).toEqual({ |
|||
prompt: "hi", |
|||
}); |
|||
expect(remapNodeDataIds("prompt", null as unknown as object, nodeMap, edgeMap)).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,82 @@ |
|||
import type { NodeType } from "@/types/nodes"; |
|||
|
|||
/** |
|||
* Rewrite node-id / edge-id references that live *inside* a node's `data` payload. |
|||
* |
|||
* When a workflow fragment is duplicated (paste) or appended (drag-drop import), |
|||
* every node gets a fresh id and every edge gets a fresh id. The top-level |
|||
* `source`/`target`/`parentId`/`groupId` references are handled by the caller, |
|||
* but several node types also embed ids deep in their `data`: |
|||
* |
|||
* - referenceSubjectList[].id -> source node id (nanoBanana / generateVideo / generate3d / generateAudio + smart variants) |
|||
* - derivedImage.sourceNodeId -> source node id (imageInput / smartImage) |
|||
* - derivedVideo.sourceNodeId -> source node id (videoInput / smartVideo) |
|||
* - derivedMedia.sourceNodeId -> source node id (image/video input) |
|||
* - clips[].sourceNodeId -> source node id (videoStitch) |
|||
* - clips[].edgeId / clipOrder[] -> edge id (videoStitch) |
|||
* - inheritedFrom -> source node id (easeCurve) |
|||
* |
|||
* A reference is only rewritten when the mapping contains its key; otherwise the |
|||
* original value is kept untouched (so references that point outside the fragment |
|||
* survive unchanged). |
|||
* |
|||
* The function is field-presence based rather than strictly `type`-driven so that |
|||
* smart variants (e.g. `smartImage` = ImageInputNodeData + NanoBananaNodeData) |
|||
* are handled uniformly. `type` is accepted for clarity/future use. |
|||
* |
|||
* Returns a new `data` object; the input is not mutated. |
|||
*/ |
|||
export function remapNodeDataIds<T>( |
|||
type: NodeType, |
|||
data: T, |
|||
nodeIdMapping: Map<string, string>, |
|||
edgeIdMapping: Map<string, string> |
|||
): T { |
|||
void type; |
|||
if (!data || typeof data !== "object") return data; |
|||
|
|||
const mapNode = (id: unknown): unknown => |
|||
typeof id === "string" && nodeIdMapping.has(id) ? nodeIdMapping.get(id)! : id; |
|||
const mapEdge = (id: unknown): unknown => |
|||
typeof id === "string" && edgeIdMapping.has(id) ? edgeIdMapping.get(id)! : id; |
|||
|
|||
const source = data as Record<string, unknown>; |
|||
const next: Record<string, unknown> = { ...source }; |
|||
|
|||
// referenceSubjectList[].id === source node id
|
|||
if (Array.isArray(source.referenceSubjectList)) { |
|||
next.referenceSubjectList = (source.referenceSubjectList as Array<Record<string, unknown>>).map( |
|||
(subject) => ({ ...subject, id: mapNode(subject.id) }) |
|||
); |
|||
} |
|||
|
|||
// derivedImage / derivedVideo / derivedMedia -> sourceNodeId
|
|||
for (const key of ["derivedImage", "derivedVideo", "derivedMedia"] as const) { |
|||
const derived = source[key]; |
|||
if (derived && typeof derived === "object" && "sourceNodeId" in derived) { |
|||
const d = derived as Record<string, unknown>; |
|||
next[key] = { ...d, sourceNodeId: mapNode(d.sourceNodeId) }; |
|||
} |
|||
} |
|||
|
|||
// videoStitch clips: sourceNodeId (node) + edgeId (edge)
|
|||
if (Array.isArray(source.clips)) { |
|||
next.clips = (source.clips as Array<Record<string, unknown>>).map((clip) => ({ |
|||
...clip, |
|||
sourceNodeId: mapNode(clip.sourceNodeId), |
|||
edgeId: mapEdge(clip.edgeId), |
|||
})); |
|||
} |
|||
|
|||
// videoStitch clipOrder: array of edge ids
|
|||
if (Array.isArray(source.clipOrder)) { |
|||
next.clipOrder = (source.clipOrder as unknown[]).map((edgeId) => mapEdge(edgeId)); |
|||
} |
|||
|
|||
// easeCurve inheritedFrom: source node id
|
|||
if (typeof source.inheritedFrom === "string") { |
|||
next.inheritedFrom = mapNode(source.inheritedFrom); |
|||
} |
|||
|
|||
return next as T; |
|||
} |
|||
Loading…
Reference in new issue