feat(canvas): finalize mixer reconnect swap and related updates

This commit is contained in:
2026-04-11 07:42:42 +02:00
parent f3dcaf89f2
commit 028fce35c2
52 changed files with 3859 additions and 272 deletions

View File

@@ -107,7 +107,8 @@ isNodePaletteEnabled // true wenn: implementiert + kein systemOutput + Template
**Kategorien:**
- `source` — Quelle (image, text, video, asset, color)
- `ai-output` — KI-Ausgabe (prompt, video-prompt, ai-text, ai-video, agent-output)
- `ai-output` — KI-Ausgabe (prompt, video-prompt, ai-text)
- `agents` — Agents (agent, agent-output)
- `transform` — Transformation (crop, bg-remove, upscale)
- `image-edit` — Bildbearbeitung (adjustments)
- `control` — Steuerung & Flow

View File

@@ -215,6 +215,24 @@ function formatExecutionRequirements(plan: AgentExecutionPlan): string {
.join("\n");
}
function formatDeliverableFirstInstructions(definition: AgentDefinition): string {
const rules = [
"Prioritize publishable, user-facing deliverables for every execution step.",
"Lead with final copy/content that can be shipped immediately.",
"Keep assumptions, rationale, and risk notes secondary and concise.",
"Do not produce reasoning-dominant output or long meta commentary.",
"When context is partial, deliver the best safe draft first and clearly note assumptions in brief form.",
];
if (definition.id === "campaign-distributor") {
rules.push(
"For Campaign Distributor steps, output channel-ready publishable copy first, then short format/assumption notes.",
);
}
return `deliverable-first rules:\n- ${rules.join("\n- ")}`;
}
export function buildExecuteMessages(input: {
definition: AgentDefinition;
locale: AgentLocale;
@@ -235,6 +253,7 @@ export function buildExecuteMessages(input: {
"Use the following compiled prompt segments:",
formatPromptSegments(segments),
`execution rules:\n- ${input.definition.executionRules.join("\n- ")}`,
formatDeliverableFirstInstructions(input.definition),
"Return one output payload per execution step keyed by step id.",
].join("\n\n"),
},

View File

@@ -26,16 +26,23 @@ export type AgentStructuredOutput = {
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
qualityChecks: string[];
body: string;
};
export type AgentStructuredMetadataEntry = {
key: string;
values: string[];
};
export type AgentStructuredOutputDraft = Partial<
AgentStructuredOutput & {
sections: Array<Partial<AgentOutputSection> | null>;
metadata: Record<string, unknown>;
}
>;
Omit<AgentStructuredOutput, "sections" | "metadata">
> & {
sections?: unknown[];
metadata?: Record<string, unknown>;
metadataEntries?: unknown[];
};
export type AgentExecutionStep = {
id: string;
@@ -178,6 +185,93 @@ function normalizeStructuredMetadata(raw: unknown): Record<string, string | stri
return metadata;
}
function normalizeStructuredMetadataEntries(raw: unknown): Record<string, string | string[]> {
if (!Array.isArray(raw)) {
return {};
}
const metadata: Record<string, string | string[]> = {};
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const record = item as Record<string, unknown>;
const key = trimString(record.key);
if (key === "") {
continue;
}
const values = normalizeStringArray(record.values);
const singleValue = trimString(record.value);
if (values.length > 1) {
metadata[key] = values;
continue;
}
if (values.length === 1) {
metadata[key] = values[0]!;
continue;
}
if (singleValue !== "") {
metadata[key] = singleValue;
}
}
return metadata;
}
function slugifyMetadataKey(value: string): string {
const normalized = value
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/Ä/g, "ae")
.replace(/Ö/g, "oe")
.replace(/Ü/g, "ue")
.replace(/ß/g, "ss")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^\x20-\x7e]+/g, " ")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return normalized || "metadata";
}
function sanitizeStructuredMetadata(raw: Record<string, string | string[]>): {
metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
} {
const metadata: Record<string, string | string[]> = {};
const metadataLabels: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(raw)) {
const trimmedKey = trimString(rawKey);
if (trimmedKey === "") {
continue;
}
const slugBase = slugifyMetadataKey(trimmedKey);
let slug = slugBase;
let suffix = 2;
while (slug in metadata) {
slug = `${slugBase}_${suffix}`;
suffix += 1;
}
metadata[slug] = value;
metadataLabels[slug] = trimmedKey;
}
return { metadata, metadataLabels };
}
function derivePreviewTextFromSections(sections: AgentOutputSection[]): string {
return sections[0]?.content ?? "";
}
@@ -365,7 +459,12 @@ export function normalizeAgentStructuredOutput(
trimString(draft.artifactType) || trimString(fallback.artifactType) || SAFE_FALLBACK_OUTPUT_TYPE;
const sections = normalizeOutputSections(draft.sections);
const previewText = trimString(draft.previewText) || derivePreviewTextFromSections(sections);
const metadata = normalizeStructuredMetadata(draft.metadata);
const metadataFromEntries = normalizeStructuredMetadataEntries(draft.metadataEntries);
const rawMetadata =
Object.keys(metadataFromEntries).length > 0
? metadataFromEntries
: normalizeStructuredMetadata(draft.metadata);
const { metadata, metadataLabels } = sanitizeStructuredMetadata(rawMetadata);
const qualityChecks = normalizeStringArray(draft.qualityChecks);
const body =
trimString(draft.body) ||
@@ -382,6 +481,7 @@ export function normalizeAgentStructuredOutput(
previewText,
sections,
metadata,
metadataLabels,
qualityChecks,
body,
};

View File

@@ -50,6 +50,23 @@ const AGENT_ALLOWED_SOURCE_TYPES = new Set<string>([
"ai-video",
]);
const MIXER_ALLOWED_SOURCE_TYPES = new Set<string>([
"image",
"asset",
"ai-image",
"render",
]);
const MIXER_TARGET_HANDLES = new Set<string>(["base", "overlay"]);
function normalizeMixerHandle(handle: string | null | undefined): string {
if (handle == null || handle === "" || handle === "null") {
return "base";
}
return handle;
}
const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set<string>(["prompt", "ai-image"]);
export type CanvasConnectionValidationReason =
@@ -66,14 +83,52 @@ export type CanvasConnectionValidationReason =
| "adjustment-target-forbidden"
| "render-source-invalid"
| "agent-source-invalid"
| "agent-output-source-invalid";
| "agent-output-source-invalid"
| "mixer-source-invalid"
| "mixer-target-handle-invalid"
| "mixer-handle-incoming-limit"
| "mixer-incoming-limit";
export function validateCanvasConnectionPolicy(args: {
sourceType: string;
targetType: string;
targetIncomingCount: number;
targetHandle?: string | null;
targetIncomingHandles?: Array<string | null | undefined>;
}): CanvasConnectionValidationReason | null {
const { sourceType, targetType, targetIncomingCount } = args;
const {
sourceType,
targetType,
targetIncomingCount,
targetHandle,
targetIncomingHandles,
} = args;
if (targetType === "mixer") {
if (!MIXER_ALLOWED_SOURCE_TYPES.has(sourceType)) {
return "mixer-source-invalid";
}
const normalizedTargetHandle = normalizeMixerHandle(targetHandle);
if (!MIXER_TARGET_HANDLES.has(normalizedTargetHandle)) {
return "mixer-target-handle-invalid";
}
if (targetIncomingCount >= 2) {
return "mixer-incoming-limit";
}
const normalizedIncomingHandles = (targetIncomingHandles ?? []).map((handle) =>
normalizeMixerHandle(handle),
);
const incomingOnHandle = normalizedIncomingHandles.filter(
(handle) => handle === normalizedTargetHandle,
).length;
if (incomingOnHandle >= 1) {
return "mixer-handle-incoming-limit";
}
}
if (targetType === "agent-output" && sourceType !== "agent") {
return "agent-output-source-invalid";
@@ -159,6 +214,14 @@ export function getCanvasConnectionValidationMessage(
return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
case "agent-output-source-invalid":
return "Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.";
case "mixer-source-invalid":
return "Mixer akzeptiert nur Bild-, Asset-, KI-Bild- oder Render-Input.";
case "mixer-target-handle-invalid":
return "Mixer akzeptiert nur die Ziel-Handles 'base' und 'overlay'.";
case "mixer-handle-incoming-limit":
return "Jeder Mixer-Handle akzeptiert nur eine eingehende Verbindung.";
case "mixer-incoming-limit":
return "Mixer-Nodes erlauben maximal zwei eingehende Verbindungen.";
default:
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
}

216
lib/canvas-mixer-preview.ts Normal file
View File

@@ -0,0 +1,216 @@
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;
offsetX: number;
offsetY: 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_OFFSET = 0;
const MIN_OFFSET = -2048;
const MAX_OFFSET = 2048;
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 normalizeOffset(value: unknown): number {
const parsed = parseNumeric(value);
if (parsed === null) {
return DEFAULT_OFFSET;
}
return clamp(parsed, MIN_OFFSET, MAX_OFFSET);
}
export function normalizeMixerPreviewData(data: unknown): Pick<
MixerPreviewState,
"blendMode" | "opacity" | "offsetX" | "offsetY"
> {
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),
offsetX: normalizeOffset(record.offsetX),
offsetY: normalizeOffset(record.offsetY),
};
}
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 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;
}
const preview = resolveRenderPreviewInputFromGraph({
nodeId: args.sourceNode.id,
graph: args.graph,
});
return preview.sourceUrl ?? 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",
...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),
});
}

View File

@@ -9,6 +9,7 @@ import type { CanvasNodeType } from "@/lib/canvas-node-types";
export type NodeCategoryId =
| "source"
| "ai-output"
| "agents"
| "transform"
| "image-edit"
| "control"
@@ -20,10 +21,11 @@ export const NODE_CATEGORY_META: Record<
> = {
source: { label: "Quelle", order: 0 },
"ai-output": { label: "KI-Ausgabe", order: 1 },
transform: { label: "Transformation", order: 2 },
"image-edit": { label: "Bildbearbeitung", order: 3 },
control: { label: "Steuerung & Flow", order: 4 },
layout: { label: "Canvas & Layout", order: 5 },
agents: { label: "Agents", order: 2 },
transform: { label: "Transformation", order: 3 },
"image-edit": { label: "Bildbearbeitung", order: 4 },
control: { label: "Steuerung & Flow", order: 5 },
layout: { label: "Canvas & Layout", order: 6 },
};
export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
@@ -85,6 +87,14 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
category: "source",
phase: 2,
}),
entry({
type: "ai-video",
label: "KI-Video-Ausgabe",
category: "source",
phase: 2,
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}),
entry({
type: "asset",
label: "Asset (Stock)",
@@ -112,18 +122,17 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}),
// Agents
entry({
type: "ai-video",
label: "KI-Video-Ausgabe",
category: "ai-output",
type: "agent",
label: "Campaign Orchestrator",
category: "agents",
phase: 2,
systemOutput: true,
disabledHint: "Wird von der KI erzeugt",
}),
entry({
type: "agent-output",
label: "Agent-Ausgabe",
category: "ai-output",
category: "agents",
phase: 2,
implemented: true,
systemOutput: true,
@@ -216,19 +225,11 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "agent",
label: "Agent",
category: "control",
phase: 2,
}),
entry({
type: "mixer",
label: "Mixer / Merge",
category: "control",
phase: 3,
implemented: false,
disabledHint: "Folgt in Phase 3",
phase: 1,
}),
entry({
type: "switch",

View File

@@ -43,6 +43,18 @@ export const CANVAS_NODE_TEMPLATES = [
templateId: "campaign-distributor",
},
},
{
type: "mixer",
label: "Mixer / Merge",
width: 360,
height: 320,
defaultData: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
},
{
type: "note",
label: "Notiz",

View File

@@ -72,6 +72,7 @@ export type CanvasSyncOpPayloadByType = {
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
clientRequestId: string;
};
removeEdge: {
@@ -477,6 +478,10 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
typeof payload.targetHandle === "string"
? payload.targetHandle
: undefined,
edgeIdToIgnore:
typeof payload.edgeIdToIgnore === "string"
? (payload.edgeIdToIgnore as Id<"edges">)
: undefined,
clientRequestId: payload.clientRequestId,
},
enqueuedAt,

View File

@@ -231,6 +231,7 @@ export const NODE_HANDLE_MAP: Record<
crop: { source: undefined, target: undefined },
render: { source: undefined, target: undefined },
agent: { target: "agent-in" },
mixer: { source: "mixer-out", target: "base" },
"agent-output": { target: "agent-output-in" },
};
@@ -292,6 +293,16 @@ export const NODE_DEFAULTS: Record<
outputNodeIds: [],
},
},
mixer: {
width: 360,
height: 320,
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
},
"agent-output": {
width: 360,
height: 260,