import { defineAction } from "@agent-native/core"; import type { ActionRunContext } from "@agent-native/core/action"; import { writeAppState, deleteAppState, } from "@agent-native/core/application-state"; import { getRequestUserEmail, getRequestOrgId, } from "@agent-native/core/server/request-context"; import { assertAccess } from "@agent-native/core/sharing"; import { delimitUntrustedReference, getGenerationCreativeContext, recordGenerationCreativeContext, resolveGenerationCreativeContext, } from "@agent-native/creative-context/server"; import type { CreativeContextReuseLabel } from "@agent-native/creative-context/types"; import { eq, inArray } from "drizzle-orm"; import { nanoid } from "nanoid"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { createAssetFromBuffer } from "../server/lib/assets.js"; import { applyPromptTemplate } from "../server/lib/generation-presets.js"; import { compilePrompt, DEFAULT_GENERATION_REFERENCE_LIMIT, generateWithManagedImageProvider, isImageGenerationSetupError, loadReferenceData, resolveImageModelForRequest, selectReferences, type ReferenceForGeneration, } from "../server/lib/generation.js"; import { applyPresetSkeleton, compositeLogo, maskFromManualMaskAlpha, maskFromPlateAlpha, prepareGptImage2SkeletonInpaintImages, } from "../server/lib/image-processing.js"; import { nowIso, parseJson, stringifyJson } from "../server/lib/json.js"; import { normalizePresetReferences, PRESET_REFERENCE_ROLE_MAP, resolvePresetReferenceFills, } from "../server/lib/preset-references.js"; import { aspectRatioValue, clampCutoutAspectRatio, normalizePresetSkeletonSpec, skeletonUsesCanonicalLogo, } from "../server/lib/preset-skeleton.js"; import { getObject } from "../server/lib/storage.js"; import { ASPECT_RATIOS, GENERATION_INTENTS, IMAGE_CATEGORIES, IMAGE_MODELS, IMAGE_QUALITY_TIERS, IMAGE_SIZES, STYLE_STRENGTHS, supportedAspectRatiosForModel, type AspectRatio, type ImageCategory, type ImageQualityTier, type PresetSkeletonSpec, type StyleBrief, } from "../shared/api.js"; import { requireGenerationSessionInLibrary, serializeAsset, } from "./_helpers.js"; import { readImageModelDefault } from "./_image-model-default.js"; import { withToolActivity } from "./_tool-activity.js"; import { upsertVariantSlot, wasVariantSlotDismissed } from "./variant-slots.js"; const IMAGE_GENERATION_TOOL_TIMEOUT_MS = 12 * 60_000; const presetReferenceFillSchema = z.object({ referenceId: z.string().min(1), assetIds: z.array(z.string().min(1)).min(1).max(4), }); const imageGenerationAgentInputSchema = z.object({ libraryId: z .string() .min(1) .describe("Brand kit/library ID to use for this image."), collectionId: z.string().optional(), presetId: z.string().optional(), sessionId: z.string().optional(), prompt: z.string().min(1), embeddedText: z.string().optional(), textPlacement: z.string().optional(), aspectRatio: z.enum(ASPECT_RATIOS).optional(), imageSize: z.enum(IMAGE_SIZES).optional(), model: z.enum(IMAGE_MODELS).optional(), tier: z.enum(IMAGE_QUALITY_TIERS).optional(), intent: z.enum(GENERATION_INTENTS).optional(), styleStrength: z.enum(STYLE_STRENGTHS).optional(), categories: z.array(z.enum(IMAGE_CATEGORIES)).optional(), referenceAssetIds: z.array(z.string()).optional(), presetReferenceFills: z.array(presetReferenceFillSchema).max(6).optional(), includeLogo: z.boolean().optional(), sourceAssetId: z.string().optional(), subjectAssetId: z.string().optional(), groundingMode: z.enum(["auto", "off", "google-search"]).optional(), }); export default defineAction({ description: "Generate one brand-consistent image from a brand kit/library. This is synchronous for images and returns the final asset with preview/download/embed URLs. Use @brand-kit mentions as libraryId and @preset mentions as presetId when present. If no preset is tagged, call list-generation-presets first and use a matching preset's presetId; the user may not know presets exist. Generate presetless only when no preset matches the request. Use generate-image-batch for multiple independent slots; do not poll image runs after this action returns.", schema: z.object({ libraryId: z .string() .min(1) .describe( "Brand kit/library ID. Pass the refId from a brand-kit @mention, or choose a kit from view-screen/list-libraries.", ), collectionId: z.string().optional(), presetId: z .string() .optional() .describe( "Generation preset ID (from a @preset mention or list-generation-presets). A preset already defines aspectRatio, imageSize, model, tier, and category. When you set presetId, OMIT those args so the preset's values are used; only pass one of them when the user explicitly asks for a value that differs from the preset.", ), sessionId: z.string().optional(), prompt: z.string().min(1), embeddedText: z .string() .optional() .describe( "Exact words to render inside the image, spelled exactly. When set, the image is allowed to contain this text; when omitted, the image avoids embedded text.", ), textPlacement: z .string() .optional() .describe( "Where/how the embedded text should appear, e.g. 'centered headline', 'lower-left label'.", ), aspectRatio: z .enum(ASPECT_RATIOS) .optional() .describe( "Image aspect ratio. When a presetId is set, omit this — the preset's aspect ratio is used. Pass a value only when there is no preset, or when the user explicitly asks for a ratio different from the preset's. Note: OpenAI image models support only 1:1, 2:3, and 3:2; gpt-image-1 is used for transparent cutout skeleton subjects, while other ratios (16:9, 9:16, 4:5, 21:9, …) require a Gemini model.", ), imageSize: z .enum(IMAGE_SIZES) .optional() .describe( "Output resolution tier. When a presetId is set, omit this — the preset's size is used unless the user explicitly requests a different one.", ), model: z .enum(IMAGE_MODELS) .optional() .describe( "Image model. Omit to use the user's picker default. Gemini models accept any aspectRatio; gpt-image-2 is the normal opaque OpenAI default, while gpt-image-1 is reserved for transparent cutout skeletons. OpenAI image models support ONLY 1:1, 2:3, and 3:2 — do not pair them with other ratios, choose a Gemini model (e.g. gemini-3-pro-image) for those instead.", ), tier: z.enum(IMAGE_QUALITY_TIERS).optional(), intent: z.enum(GENERATION_INTENTS).default("generate"), styleStrength: z.enum(STYLE_STRENGTHS).default("balanced"), categories: z.array(z.enum(IMAGE_CATEGORIES)).optional(), referenceAssetIds: z .array(z.string()) .optional() .describe( "Exact reference assets to use. When omitted, the server deterministically chooses a small relevant subset from the latest library references.", ), presetReferenceFills: z .array(presetReferenceFillSchema) .max(6) .optional() .describe( 'Per-run images for the preset\'s reference board (see the tagged preset brief or list-generation-presets settings.presetReferences). Each fill REPLACES the images of one variable entry by id, e.g. [{ referenceId: "guest-speaker", assetIds: ["..."] }]. Required entries without pinned images MUST be filled or the call fails. Only valid when presetId is set.', ), includeLogo: z.coerce .boolean() .optional() .describe( "Composite the library's pixel-perfect canonical logo onto the finished image. When omitted, the selected preset's logo setting is used; pass an explicit value to override it. No-op if the library has no canonical logo.", ), slotId: z.string().optional(), variantBatchId: z.string().optional(), variantScopeId: z .string() .optional() .describe( "Internal UI state scope for live candidate slots. Usually omitted; embedded picker UIs pass a browser-tab scope.", ), dismissible: z.coerce .boolean() .default(true) .describe( "When false, always create the finished asset even if live variant slot UI state is cleared before the provider returns. Picker batch candidates use this so every requested option is returned.", ), sourceAssetId: z.string().optional(), subjectAssetId: z .string() .optional() .describe( "Subject image to preserve for restyle/edit runs. The subject is attached before style references.", ), groundingMode: z.enum(["auto", "off", "google-search"]).default("auto"), // Audit metadata. Defaulted to "chat" because that's the agent's typical // entry point; the UI Generate popover and A2A callers override. source: z.enum(["chat", "ui", "a2a"]).default("chat"), callerAppId: z .string() .optional() .describe( "Set by A2A callers (e.g. 'slides', 'design'). Audit log filters on this.", ), creativeContextRequestId: z .string() .optional() .describe( "Internal immutable creative-context request ID supplied by the Assets picker.", ), contextModeOverride: z .literal("off") .optional() .describe( "Disable Creative Context for this image generation only without changing the saved preference.", ), activateSessionAsset: z.coerce .boolean() .default(true) .describe( "When false, attach the output to the session without making it the active asset. Batch generation selects the active asset deterministically after all slots finish.", ), }), agentInputSchema: imageGenerationAgentInputSchema, parallelSafe: true, timeoutMs: IMAGE_GENERATION_TOOL_TIMEOUT_MS, run: async (input, context?: ActionRunContext) => { const imageModelDefault = await readImageModelDefault(); const libraryId = input.libraryId; if (!libraryId) { throw new Error( "No brand kit selected. Tag a brand kit with @ or pass libraryId.", ); } const args = { ...input, libraryId, }; await assertAccess("asset-library", args.libraryId, "editor"); const db = getDb(); const [library] = await db .select() .from(schema.assetLibraries) .where(eq(schema.assetLibraries.id, args.libraryId)) .limit(1); if (!library) throw new Error("Asset library not found."); const session = args.sessionId ? await requireGenerationSessionInLibrary(args.sessionId, args.libraryId) : null; const contextOff = args.contextModeOverride === "off"; const lineageAssetId = args.sourceAssetId ?? args.subjectAssetId; const [lineageAsset] = lineageAssetId ? await db .select({ id: schema.assets.id, libraryId: schema.assets.libraryId, }) .from(schema.assets) .where(eq(schema.assets.id, lineageAssetId)) .limit(1) : [null]; if ( lineageAssetId && (!lineageAsset || lineageAsset.libraryId !== args.libraryId) ) { throw new Error("Source asset must belong to this asset library."); } const sessionCreativeContext = session && !contextOff ? await getGenerationCreativeContext( { appId: "assets", artifactType: "generation-session", artifactId: session.id, }, { artifactAccess: { resourceType: "asset-library", resourceId: args.libraryId, }, }, ) : null; const pickerCreativeContext = args.creativeContextRequestId && !contextOff ? await getGenerationCreativeContext({ appId: "assets", artifactType: "generation-request", artifactId: args.creativeContextRequestId, }) : null; const lineageCreativeContext = lineageAsset && !contextOff ? await getGenerationCreativeContext( { appId: "assets", artifactType: "asset", artifactId: lineageAsset.id, }, { artifactAccess: { resourceType: "asset-library", resourceId: args.libraryId, }, }, ) : null; if ( args.creativeContextRequestId && !contextOff && !pickerCreativeContext ) { throw new Error( "The creative-context picker request is missing or inaccessible. Reopen the picker to preserve its generation context.", ); } if (session && !contextOff && !sessionCreativeContext) { throw new Error( "The generation session has no immutable creative-context snapshot. Create a new session before generating more images.", ); } if ( sessionCreativeContext && pickerCreativeContext && sessionCreativeContext.contextPackId !== pickerCreativeContext.contextPackId ) { throw new Error( "The picker and generation session use different creative-context snapshots.", ); } const immutableContexts = [ sessionCreativeContext, pickerCreativeContext, lineageCreativeContext, ].filter((value) => value !== null); if ( immutableContexts.some( (value) => value.contextPackId !== immutableContexts[0]?.contextPackId, ) ) { throw new Error( "The generation session, picker, and source asset use different creative-context snapshots.", ); } const storedCreativeContext = sessionCreativeContext ?? pickerCreativeContext ?? lineageCreativeContext ?? null; const creativeContext = contextOff ? await resolveGenerationCreativeContext({ role: "assets", contextModeOverride: "off", }) : storedCreativeContext?.contextPackId ? await resolveGenerationCreativeContext({ role: "assets", contextPackId: storedCreativeContext.contextPackId, contextPackSource: "inherited", }) : storedCreativeContext ? { contextMode: storedCreativeContext.contextMode, contextPackId: null, reuseLabels: [], results: [], } : await resolveGenerationCreativeContext({ query: args.prompt, role: "assets", }); const creativeContextText = creativeContext.results .map((result) => `${result.kind}: ${result.title}\n${result.excerpt}`) .join("\n\n") .split("<<>>") .join("") .split("<<>>") .join("") .trim(); const creativeContextInstructions = creativeContextText ? delimitUntrustedReference(creativeContextText) : ""; const creativeContextReuseLabels = creativeContext.reuseLabels as CreativeContextReuseLabel[]; const creativeContextProvenance = { contextMode: creativeContext.contextMode, contextPackId: creativeContext.contextPackId, reuseLabels: creativeContextReuseLabels, }; const elementProvenanceFor = (elementId: string) => creativeContextProvenance.reuseLabels.length ? creativeContextProvenance.reuseLabels.map((label) => ({ elementId: label.elementId ?? elementId, influence: label.influence ?? ("reference-conditioned" as const), ...(label.itemId ? { itemId: label.itemId } : {}), ...(label.itemVersionId ? { itemVersionId: label.itemVersionId } : {}), label: label.label, })) : [ { elementId, influence: "generated" as const, label: "Net-new image", }, ]; if ( session?.presetId && args.presetId && args.presetId !== session.presetId ) { throw new Error("Generation preset does not match this session."); } if ( session?.collectionId && args.collectionId && args.collectionId !== session.collectionId ) { throw new Error("Collection does not match this session."); } const resolvedPresetId = session?.presetId ?? args.presetId ?? undefined; const [preset] = resolvedPresetId ? await db .select() .from(schema.assetGenerationPresets) .where(eq(schema.assetGenerationPresets.id, resolvedPresetId)) .limit(1) : [null]; if (resolvedPresetId && !preset) { throw new Error("Generation preset not found."); } if (preset && preset.libraryId !== args.libraryId) { throw new Error("Generation preset does not belong to this library."); } if ( session?.collectionId && preset?.collectionId && preset.collectionId !== session.collectionId ) { throw new Error( "Generation preset belongs to a different session collection.", ); } if ( !session?.collectionId && args.collectionId && preset?.collectionId && preset.collectionId !== args.collectionId ) { throw new Error("Generation preset belongs to a different collection."); } const resolvedCollectionId = session?.collectionId ?? preset?.collectionId ?? args.collectionId ?? undefined; const [collection] = resolvedCollectionId ? await db .select() .from(schema.assetCollections) .where(eq(schema.assetCollections.id, resolvedCollectionId)) .limit(1) : [null]; if (collection && collection.libraryId !== args.libraryId) { throw new Error("Collection does not belong to this asset library."); } if (args.intent === "edit" && !args.subjectAssetId) { throw new Error("Edit runs require subjectAssetId."); } if (args.subjectAssetId) { const [subject] = await db .select({ id: schema.assets.id, libraryId: schema.assets.libraryId, mimeType: schema.assets.mimeType, }) .from(schema.assets) .where(eq(schema.assets.id, args.subjectAssetId)) .limit(1); if (!subject || subject.libraryId !== args.libraryId) { throw new Error("Subject asset must belong to this asset library."); } if (!subject.mimeType.startsWith("image/")) { throw new Error("Subject asset must be an image."); } } const styleBrief = { ...parseJson(library.styleBrief, {}), ...parseJson(collection?.styleBrief, {}), }; const presetSettings = parseJson<{ tier?: ImageQualityTier; includeLogo?: boolean; skeletonSpec?: unknown; presetReferences?: unknown; }>(preset?.settings, {}); const skeletonSpec = normalizePresetSkeletonSpec( presetSettings.skeletonSpec, ); const presetReferences = normalizePresetReferences( presetSettings.presetReferences, ); if (args.presetReferenceFills?.length && !preset) { throw new Error( "presetReferenceFills requires a presetId whose preset declares a reference board.", ); } const resolvedPresetReferences = resolvePresetReferenceFills({ entries: presetReferences, fills: args.presetReferenceFills, presetTitle: preset?.title ?? "", }); const boardAssignments = Object.fromEntries( resolvedPresetReferences.map((item) => [item.entry.id, item.assetIds]), ); const isSkeletonCutout = skeletonSpec?.contentMode === "cutout"; const outputAspectRatio = (args.aspectRatio ?? preset?.aspectRatio ?? collection?.defaultAspectRatio ?? "16:9") as AspectRatio; const resolvedImageSize = (args.imageSize ?? preset?.imageSize ?? collection?.defaultImageSize ?? "2K") as (typeof IMAGE_SIZES)[number]; const resolvedTier = args.tier ?? presetSettings.tier; const requestedIncludeLogo = args.includeLogo ?? presetSettings.includeLogo ?? false; const resolvedIncludeLogo = requestedIncludeLogo && !skeletonUsesCanonicalLogo(skeletonSpec); const category = (args.categories?.[0] ?? preset?.category ?? collection?.category) as ImageCategory | undefined; const selectedModel = resolveImageModelForRequest({ explicitModel: args.model, imageModelDefault, explicitTier: args.tier, resolvedTier, category, presetModel: preset?.model as (typeof IMAGE_MODELS)[number] | undefined, embeddedText: args.embeddedText, }) as (typeof IMAGE_MODELS)[number]; if ( isSkeletonCutout && args.model && args.model !== "gpt-image-1" && args.model !== "gpt-image-2" ) { throw new Error( "Cutout skeleton presets require gpt-image-1 for transparent output or gpt-image-2 for mask inpainting. Remove the explicit model override or choose one of those models.", ); } const resolvedModel = ( isSkeletonCutout && selectedModel !== "gpt-image-2" ? "gpt-image-1" : selectedModel ) as (typeof IMAGE_MODELS)[number]; if (isSkeletonCutout && selectedModel !== resolvedModel) { logSkeletonGeneration("model_overridden", { presetId: preset?.id, selectedModel, model: resolvedModel, }); } // Mirror the create/update-generation-preset guard for per-run model // overrides: subject board photos on a model without character // consistency would silently ignore the people they exist to preserve. if ( resolvedModel === "gemini-2.5-flash-image" && resolvedPresetReferences.some((item) => item.entry.role === "subject") ) { throw new Error( "Subject reference entries need a model with character consistency. Use gemini-3.1-flash-image, gemini-3-pro-image, gpt-image-1, or gpt-image-2.", ); } const resolvedCategories = args.categories ?? (preset?.category ? ([preset.category] as any) : undefined); const skeletonReferenceExclusionIds = skeletonCompositeAssetIds(skeletonSpec); const boardRefs = await loadPresetReferenceBoardRefs({ db, libraryId: args.libraryId, resolved: resolvedPresetReferences, }); const boardAssetIdSet = new Set(boardRefs.map((ref) => ref.id)); const referenceExclusionIds = [ ...(skeletonReferenceExclusionIds ?? []), ...boardAssetIdSet, ]; const skeletonAssets = skeletonSpec ? await loadSkeletonAssets({ spec: skeletonSpec, libraryId: args.libraryId, canonicalLogoAssetId: library.canonicalLogoAssetId, appendCanonicalLogo: resolvedIncludeLogo && !(isSkeletonCutout && resolvedModel === "gpt-image-2"), }) : null; const useSkeletonInpaint = isSkeletonCutout && Boolean(skeletonAssets?.backgroundAsset) && resolvedModel === "gpt-image-2"; const skeletonInpaint = useSkeletonInpaint ? await inpaintReferencesForSkeleton(skeletonAssets) : null; const resolvedAspectRatio = useSkeletonInpaint && skeletonInpaint ? closestSupportedAspectRatioForSize( skeletonInpaint.size, supportedAspectRatiosForModel("gpt-image-2"), ) : isSkeletonCutout ? clampCutoutAspectRatio({ aspectRatio: outputAspectRatio, supported: supportedAspectRatiosForModel("gpt-image-1"), }) : outputAspectRatio; if ( isSkeletonCutout && !useSkeletonInpaint && outputAspectRatio !== resolvedAspectRatio ) { logSkeletonGeneration("aspect_ratio_clamped", { presetId: preset?.id, outputAspectRatio, subjectAspectRatio: resolvedAspectRatio, }); } const promptForRun = applyPromptTemplate( preset?.promptTemplate, args.prompt, ); const presetInstructions = preset ? [ `Generation preset: ${preset.title}.`, preset.description ? `Preset description: ${preset.description}` : "", preset.textPolicy ? `Text policy: ${preset.textPolicy}` : "", preset.referencePolicy ? `Reference policy: ${preset.referencePolicy}` : "", ] .filter(Boolean) .join("\n") : ""; let references: ReferenceForGeneration[]; const baseReferenceLimit = args.intent !== "restyle" && preset?.referencePolicy === "explicit" && !args.referenceAssetIds?.length ? 0 : DEFAULT_GENERATION_REFERENCE_LIMIT; if (useSkeletonInpaint) { const guidanceReferenceLimit = Math.max( 0, baseReferenceLimit - boardRefs.length, ); const guidanceReferences = baseReferenceLimit > 0 || args.referenceAssetIds?.length ? await selectReferences({ libraryId: args.libraryId, collectionId: resolvedCollectionId, categories: resolvedCategories, referenceAssetIds: args.referenceAssetIds, excludeAssetIds: referenceExclusionIds, sourceAssetId: args.sourceAssetId, subjectAssetId: args.subjectAssetId, intent: args.intent, limit: guidanceReferenceLimit, }) : []; const inpaintReferences = skeletonInpaint?.references ?? []; const [plateReference, ...maskReferences] = inpaintReferences; references = [ ...(plateReference ? [plateReference] : []), ...boardRefs.map(referenceForSkeletonInpaintGuidance), ...guidanceReferences.map(referenceForSkeletonInpaintGuidance), ...maskReferences, ]; } else { const backgroundPlateReference = backgroundPlateReferenceForSkeleton(skeletonAssets); const referenceLimit = Math.max( 0, (backgroundPlateReference && baseReferenceLimit > 0 ? baseReferenceLimit + 1 : baseReferenceLimit) - boardRefs.length, ); const autoReferences = referenceLimit > 0 || args.referenceAssetIds?.length ? await selectReferences({ libraryId: args.libraryId, collectionId: resolvedCollectionId, categories: resolvedCategories, referenceAssetIds: args.referenceAssetIds, excludeAssetIds: referenceExclusionIds, sourceAssetId: args.sourceAssetId, subjectAssetId: args.subjectAssetId, intent: args.intent, limit: referenceLimit, }) : []; references = [ ...(backgroundPlateReference ? [backgroundPlateReference] : []), ...boardRefs, ...autoReferences, ]; } const compiledPrompt = compilePrompt({ libraryTitle: library.title, styleBrief, customInstructions: [ library.customInstructions, presetInstructions, creativeContextInstructions, ] .filter((item) => item?.trim()) .join("\n\n"), prompt: promptForRun, embeddedText: args.embeddedText, textPlacement: args.textPlacement, referenceCount: references.length, includeLogo: resolvedIncludeLogo, skeletonContentMode: skeletonSpec?.contentMode ?? null, hasBackgroundPlate: Boolean( isSkeletonCutout && !useSkeletonInpaint && skeletonAssets?.backgroundAsset, ), skeletonInpaint: useSkeletonInpaint, aspectRatio: resolvedAspectRatio, imageSize: resolvedImageSize, category, intent: args.intent, styleStrength: args.styleStrength, referenceBoard: resolvedPresetReferences.map((item) => ({ label: item.entry.label, role: item.entry.role, count: item.assetIds.length, description: item.entry.description, })), }); const runId = nanoid(); const now = nowIso(); const slotId = args.slotId ?? runId; const variantScopeId = args.variantScopeId ?? context?.threadId ?? null; // Capture identity at insert time so the org-admin audit log can filter // by owner / org without re-resolving who triggered the run later. const ownerEmail = getRequestUserEmail() ?? null; const orgId = getRequestOrgId() ?? null; const referenceSelection = { mode: args.referenceAssetIds?.length ? "explicit" : references.some((ref) => ref.selectionReason === "anchor") ? "anchored-deterministic" : "deterministic", limit: args.referenceAssetIds?.length ? args.referenceAssetIds.length : references.length, requestedAssetIds: args.referenceAssetIds ?? [], selectedAssetIds: references.map((ref) => ref.id), anchorAssetIds: references .filter((ref) => ref.selectionReason === "anchor") .map((ref) => ref.id), sourceAssetId: args.sourceAssetId, subjectAssetId: args.subjectAssetId, selectionReasons: Object.fromEntries( references.map((ref) => [ref.id, ref.selectionReason ?? "scored"]), ), boardAssignments, }; const settingsUsed = { model: resolvedModel, tier: resolvedTier ?? null, intent: args.intent, styleStrength: args.styleStrength, aspectRatio: resolvedAspectRatio, imageSize: resolvedImageSize, groundingMode: args.groundingMode, includeLogo: resolvedIncludeLogo, requestedIncludeLogo, skeletonSpec: skeletonSpec ?? null, outputAspectRatio, subjectAspectRatio: resolvedAspectRatio, categories: resolvedCategories ?? [], collectionId: resolvedCollectionId ?? null, presetId: preset?.id ?? null, sessionId: session?.id ?? null, embeddedText: args.embeddedText ?? null, textPlacement: args.textPlacement ?? null, customInstructions: library.customInstructions ?? "", boardAssignments, }; const dismissibleSlot = args.dismissible !== false && Boolean(slotId); const baseMetadata = { slotId, variantBatchId: args.variantBatchId ?? null, threadId: context?.threadId ?? null, variantScopeId, dismissible: dismissibleSlot, sourceAssetId: args.sourceAssetId, subjectAssetId: args.subjectAssetId, embeddedText: args.embeddedText, textPlacement: args.textPlacement, intent: args.intent, styleStrength: args.styleStrength, tier: resolvedTier, includeLogo: resolvedIncludeLogo, requestedIncludeLogo, skeletonSpec: skeletonSpec ?? null, outputAspectRatio, subjectAspectRatio: resolvedAspectRatio, categories: resolvedCategories ?? [], presetId: preset?.id, sessionId: session?.id, referenceSelection, settingsUsed, creativeContext: creativeContextProvenance, }; await db.insert(schema.assetGenerationRuns).values({ id: runId, libraryId: args.libraryId, collectionId: resolvedCollectionId ?? null, presetId: preset?.id ?? null, sessionId: session?.id ?? null, prompt: args.prompt, compiledPrompt, model: resolvedModel, aspectRatio: outputAspectRatio, imageSize: resolvedImageSize, groundingMode: args.groundingMode, referenceAssetIds: stringifyJson(references.map((ref) => ref.id)), status: "pending", source: args.source, callerAppId: args.callerAppId ?? null, ownerEmail, orgId, metadata: stringifyJson(baseMetadata), createdAt: now, }); await recordGenerationCreativeContext( { appId: "assets", artifactType: "generation-run", artifactId: runId, ...creativeContextProvenance, elementProvenance: elementProvenanceFor(runId), }, { artifactAccess: { resourceType: "asset-library", resourceId: args.libraryId, }, }, ); await upsertVariantSlot({ runId, batchId: args.variantBatchId ?? null, libraryId: args.libraryId, collectionId: resolvedCollectionId ?? null, presetId: preset?.id ?? null, sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, prompt: args.prompt, slotId, status: "pending", }); try { const generated = await withToolActivity( context, { label: "Generating image.", ongoingLabel: "Still generating image.", // Intentionally no explicit `tool`: withToolActivity falls back to // context.actionName, which is the ACTUAL dispatched tool. When this // action is invoked directly that is "generate-image"; when it runs as // a sub-step of generate-image-batch / rerun-generation-run it is the // parent tool, so the activity event matches the real tool_start card // instead of spawning an orphan "generate-image" activity card. }, () => generateWithManagedImageProvider({ prompt: promptForRun, compiledPrompt, references, model: resolvedModel, mode: useSkeletonInpaint ? "edit" : undefined, aspectRatio: resolvedAspectRatio, imageSize: resolvedImageSize, background: isSkeletonCutout && !useSkeletonInpaint ? "transparent" : undefined, groundingMode: args.groundingMode, intent: args.intent, styleStrength: args.styleStrength, runId, libraryId: args.libraryId, collectionId: resolvedCollectionId ?? null, source: args.source, callerAppId: args.callerAppId, hasBoardReferences: boardRefs.length > 0, }), ); await deleteAppState("image-generation-setup").catch(() => {}); let image = generated.image; let mimeType = generated.mimeType; if (skeletonAssets && !useSkeletonInpaint) { image = await applyPresetSkeleton({ subject: image, spec: skeletonAssets.spec, canvasAspectRatio: outputAspectRatio, backgroundAsset: skeletonAssets.backgroundAsset, canonicalLogo: skeletonAssets.canonicalLogo, foregroundAssets: skeletonAssets.foregroundAssets, }); mimeType = "image/png"; } else if (resolvedIncludeLogo && library.canonicalLogoAssetId) { const [logo] = await db .select() .from(schema.assets) .where(eq(schema.assets.id, library.canonicalLogoAssetId)) .limit(1); if (logo) { image = await compositeLogo({ image, logo: await getObject(logo.objectKey), }); mimeType = "image/png"; } } if ( dismissibleSlot && (await wasVariantSlotDismissed(args.libraryId, slotId, { threadId: context?.threadId ?? null, variantScopeId, })) ) { await db .update(schema.assetGenerationRuns) .set({ status: "completed", completedAt: nowIso(), metadata: stringifyJson({ ...baseMetadata, dismissed: true, slotId, referenceSelection, settingsUsed, provider: generated.provider, providerGenerationId: generated.providerGenerationId, creditsCharged: generated.creditsCharged, creativeContext: creativeContextProvenance, }), }) .where(eq(schema.assetGenerationRuns.id, runId)); return { runId, dismissed: true, artifactType: "image", Artifacts: [], ...creativeContextProvenance, }; } const asset = await withToolActivity( context, { label: "Saving generated image.", ongoingLabel: "Still saving generated image.", // See above: omit `tool` so the activity is tagged with the real // dispatched tool (context.actionName) rather than a hardcoded // "generate-image" that orphans under batch/rerun. }, () => createAssetFromBuffer({ libraryId: args.libraryId, collectionId: resolvedCollectionId ?? null, buffer: image, mimeType, role: "generated", status: "candidate", prompt: args.prompt, model: generated.model, aspectRatio: outputAspectRatio, imageSize: resolvedImageSize, generationRunId: runId, metadata: { provider: generated.provider, compiledPrompt, referenceAssetIds: references.map((ref) => ref.id), sourceAssetId: args.sourceAssetId, subjectAssetId: args.subjectAssetId, intent: args.intent, styleStrength: args.styleStrength, tier: resolvedTier, includeLogo: resolvedIncludeLogo, requestedIncludeLogo, skeletonSpec: skeletonSpec ?? null, outputAspectRatio, subjectAspectRatio: resolvedAspectRatio, presetId: preset?.id, sessionId: session?.id, generated: true, sourceUrl: generated.sourceUrl, providerGenerationId: generated.providerGenerationId, creditsCharged: generated.creditsCharged, creativeContext: creativeContextProvenance, }, category, }), ); await recordGenerationCreativeContext( { appId: "assets", artifactType: "asset", artifactId: asset.id, ...creativeContextProvenance, elementProvenance: elementProvenanceFor(asset.id), }, { artifactAccess: { resourceType: "asset-library", resourceId: args.libraryId, }, }, ); if (session) { const itemCreatedAt = nowIso(); await db.insert(schema.assetGenerationSessionItems).values({ id: nanoid(), sessionId: session.id, assetId: asset.id, generationRunId: runId, role: args.activateSessionAsset ? "active" : "candidate", note: null, sortOrder: 100, createdAt: itemCreatedAt, }); if (args.activateSessionAsset) { await db .update(schema.assetGenerationSessions) .set({ activeAssetId: asset.id, updatedAt: itemCreatedAt }) .where(eq(schema.assetGenerationSessions.id, session.id)); } } await db .update(schema.assetGenerationRuns) .set({ status: "completed", completedAt: nowIso(), metadata: stringifyJson({ ...baseMetadata, assetId: asset.id, outputAssetIds: [asset.id], slotId, sourceAssetId: args.sourceAssetId, subjectAssetId: args.subjectAssetId, intent: args.intent, styleStrength: args.styleStrength, tier: resolvedTier, includeLogo: resolvedIncludeLogo, requestedIncludeLogo, skeletonSpec: skeletonSpec ?? null, outputAspectRatio, subjectAspectRatio: resolvedAspectRatio, categories: resolvedCategories ?? [], referenceSelection, settingsUsed, provider: generated.provider, providerGenerationId: generated.providerGenerationId, creditsCharged: generated.creditsCharged, creativeContext: creativeContextProvenance, }), }) .where(eq(schema.assetGenerationRuns.id, runId)); const serialized = serializeAsset(asset); await upsertVariantSlot({ runId, batchId: args.variantBatchId ?? null, libraryId: args.libraryId, collectionId: resolvedCollectionId ?? null, presetId: preset?.id ?? null, sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, prompt: args.prompt, slotId, status: "ready", assetId: asset.id, previewUrl: serialized.previewUrl, thumbnailUrl: serialized.thumbnailUrl, }); return { ...serialized, runId, artifactType: "image", Artifacts: [ `Image: ${serialized.url} (ID: ${asset.id}, Run: ${runId})`, ], ...creativeContextProvenance, }; } catch (err) { const message = err instanceof Error ? err.message : "Image generation failed."; if (isImageGenerationSetupError(err)) { await writeAppState("image-generation-setup", { status: "needs-setup", message, at: nowIso(), }).catch(() => {}); } await db .update(schema.assetGenerationRuns) .set({ status: "failed", error: message, completedAt: nowIso() }) .where(eq(schema.assetGenerationRuns.id, runId)); if ( dismissibleSlot && (await wasVariantSlotDismissed(args.libraryId, slotId, { threadId: context?.threadId ?? null, variantScopeId, })) ) { throw err; } await upsertVariantSlot({ runId, batchId: args.variantBatchId ?? null, libraryId: args.libraryId, collectionId: resolvedCollectionId ?? null, presetId: preset?.id ?? null, sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, prompt: args.prompt, slotId, status: "failed", error: message, }); throw err; } }, }); function skeletonCompositeAssetIds( spec: PresetSkeletonSpec | null, ): string[] | undefined { if (!spec) return undefined; const ids = new Set(); if (spec.background.type === "asset") ids.add(spec.background.assetId); if (spec.mask?.type === "asset") ids.add(spec.mask.assetId); for (const layer of spec.foreground ?? []) { if (layer.source !== "canonicalLogo") ids.add(layer.source.assetId); } return ids.size ? [...ids] : undefined; } function backgroundPlateReferenceForSkeleton( skeletonAssets: LoadedSkeletonAssets | null, ): ReferenceForGeneration | null { if ( !skeletonAssets?.backgroundAsset || !skeletonAssets.backgroundAssetId || !skeletonAssets.backgroundMimeType ) { return null; } return { id: skeletonAssets.backgroundAssetId, role: "background_reference", category: "skeleton", mimeType: skeletonAssets.backgroundMimeType, data: skeletonAssets.backgroundAsset.toString("base64"), selectionReason: "explicit", }; } async function loadPresetReferenceBoardRefs(input: { db: any; libraryId: string; resolved: Array<{ entry: { id: string; role: keyof typeof PRESET_REFERENCE_ROLE_MAP; }; assetIds: string[]; }>; }): Promise { const assetIds = [ ...new Set(input.resolved.flatMap((item) => item.assetIds)), ]; if (!assetIds.length) return []; const rows = await input.db .select() .from(schema.assets) .where(inArray(schema.assets.id, assetIds)); const byId = new Map( (rows as any[]).map((asset) => [asset.id, asset]), ); const valid = assetIds.every((assetId) => { const asset = byId.get(assetId); return ( asset && asset.libraryId === input.libraryId && typeof asset.mimeType === "string" && asset.mimeType.startsWith("image/") && asset.status !== "archived" && asset.status !== "failed" ); }); if (!valid) { throw new Error( "Reference board images must be images in this asset library.", ); } const refs: ReferenceForGeneration[] = []; for (const item of input.resolved) { const assets = item.assetIds .map((assetId) => byId.get(assetId)) .filter((asset): asset is any => asset != null); refs.push( ...(await loadReferenceData( assets, () => PRESET_REFERENCE_ROLE_MAP[item.entry.role], () => `preset-ref:${item.entry.id}`, )), ); } return refs; } async function inpaintReferencesForSkeleton( skeletonAssets: LoadedSkeletonAssets | null, ): Promise<{ references: ReferenceForGeneration[]; size: { width: number; height: number }; }> { if ( !skeletonAssets?.backgroundAsset || !skeletonAssets.backgroundAssetId || !skeletonAssets.backgroundMimeType ) { throw new Error("Skeleton inpainting requires a background plate image."); } const mask = skeletonAssets.maskAsset ? await maskFromManualMaskAlpha({ mask: skeletonAssets.maskAsset, plate: skeletonAssets.backgroundAsset, }) : await maskFromPlateAlpha(skeletonAssets.backgroundAsset); const prepared = await prepareGptImage2SkeletonInpaintImages({ plate: skeletonAssets.backgroundAsset, mask, }); return { size: prepared.size, references: [ { id: skeletonAssets.backgroundAssetId, role: "edit_target", category: "skeleton", mimeType: prepared.resized ? "image/png" : skeletonAssets.backgroundMimeType, data: prepared.plate.toString("base64"), selectionReason: "explicit", }, { id: skeletonAssets.maskAssetId ?? `${skeletonAssets.backgroundAssetId}:mask`, role: "mask", category: "skeleton", mimeType: "image/png", data: prepared.mask.toString("base64"), selectionReason: "explicit", }, ], }; } function referenceForSkeletonInpaintGuidance( ref: ReferenceForGeneration, ): ReferenceForGeneration { if ( ref.role !== "subject_reference" && ref.role !== "generated" && ref.role !== "edit_target" ) { return ref; } return { ...ref, role: "product_reference", }; } function closestSupportedAspectRatioForSize( size: { width: number; height: number }, supported: readonly AspectRatio[], ): AspectRatio { const target = size.width / size.height; return ( [...supported].sort( (left, right) => Math.abs(aspectRatioValue(left) - target) - Math.abs(aspectRatioValue(right) - target), )[0] ?? "1:1" ); } type LoadedSkeletonAssets = { spec: PresetSkeletonSpec; backgroundAssetId?: string; backgroundMimeType?: string; backgroundAsset?: Buffer; maskAssetId?: string; maskAsset?: Buffer; canonicalLogo?: Buffer; foregroundAssets: Record; }; async function loadSkeletonAssets(input: { spec: PresetSkeletonSpec; libraryId: string; canonicalLogoAssetId?: string | null; appendCanonicalLogo: boolean; }): Promise { const db = getDb(); const spec = appendCanonicalLogoToSkeleton( input.spec, input.appendCanonicalLogo, ); const assetIds = new Set(); if (spec.background.type === "asset") assetIds.add(spec.background.assetId); if (spec.mask?.type === "asset") assetIds.add(spec.mask.assetId); for (const layer of spec.foreground ?? []) { if (layer.source !== "canonicalLogo") assetIds.add(layer.source.assetId); } if (input.canonicalLogoAssetId) assetIds.add(input.canonicalLogoAssetId); const buffers = new Map(); const mimeTypes = new Map(); for (const assetId of assetIds) { const [asset] = await db .select() .from(schema.assets) .where(eq(schema.assets.id, assetId)) .limit(1); if (!asset) { if (assetId === input.canonicalLogoAssetId) continue; throw new Error("Skeleton asset not found."); } if (asset.libraryId !== input.libraryId) { throw new Error("Skeleton assets must belong to the selected brand kit."); } if (!asset.mimeType.startsWith("image/")) { throw new Error("Skeleton assets must be images."); } buffers.set(assetId, await getObject(asset.objectKey)); mimeTypes.set(assetId, asset.mimeType); } const foregroundAssets: Record = {}; for (const layer of spec.foreground ?? []) { if (layer.source !== "canonicalLogo") { const buffer = buffers.get(layer.source.assetId); if (buffer) foregroundAssets[layer.source.assetId] = buffer; } } return { spec, backgroundAssetId: spec.background.type === "asset" ? spec.background.assetId : undefined, backgroundMimeType: spec.background.type === "asset" ? mimeTypes.get(spec.background.assetId) : undefined, backgroundAsset: spec.background.type === "asset" ? buffers.get(spec.background.assetId) : undefined, maskAssetId: spec.mask?.type === "asset" ? spec.mask.assetId : undefined, maskAsset: spec.mask?.type === "asset" ? buffers.get(spec.mask.assetId) : undefined, canonicalLogo: input.canonicalLogoAssetId ? buffers.get(input.canonicalLogoAssetId) : undefined, foregroundAssets, }; } function appendCanonicalLogoToSkeleton( spec: PresetSkeletonSpec, appendCanonicalLogo: boolean, ): PresetSkeletonSpec { if (!appendCanonicalLogo || skeletonUsesCanonicalLogo(spec)) return spec; return { ...spec, foreground: [ ...(spec.foreground ?? []), { source: "canonicalLogo", x: 0.805, y: 0.06, w: 0.16 }, ], }; } function logSkeletonGeneration( event: string, fields: Record, ): void { if (process.env.NODE_ENV === "test") return; const detail = Object.entries(fields) .filter(([, value]) => value !== undefined) .map(([key, value]) => `${key}=${value}`) .join(" "); console.info( `[assets] preset-skeleton ${event}${detail ? ` ${detail}` : ""}`, ); }