← Files WixARCHIVED FILE
skills/wix-headless-templates/shared/seed/images.mjs
9.72 KB · Oct 8, 2026 · 12:02 UTC
// Shared seed util: entity images by URL or by AI GENERATION (Wix AI / Runware via the
// wixapis proxy). BUILD-TIME only — imported by the vertical seed scripts, never shipped.
//
// The contract every seed relies on:
// - RESILIENT: nothing here ever throws out of resolveItemImages — a failed image resolves
// to null and the entity stays text-only; the seed's exit code never depends on images.
// - PARALLEL: all images resolve in one concurrent wave (each generation is its own
// single-task request — the google model 504s when one body bundles ≥3 tasks).
// - PASS-2: seeds create entities first, then attach what this returns — an image is never
// a precondition for an entity.
//
// Generation costs 1 Wix AI credit per image, billed to the account behind the site.
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename, extname } from "node:path";
import { createHash } from "node:crypto";
const API = "https://www.wixapis.com";
// Order = cheap-and-permissive first (runware ~0.009 credits/img, ~5s, loosest content
// filter), then google (best fidelity, ~0.14, ~25s; rejects steps/CFGScale and free-form
// sizes), then bfl (strictest filter — refuses trademark-ish prompts). A refusal or failure
// falls through to the next model.
const MODELS = ["runware:400@1", "google:4@2", "bfl:5@1"];
/** Allowed dimensions: 1024×1024 (square — entities), 1376×768 (16:9 hero), 1200×896 (4:3). */
export const IMAGE_SIZES = { square: [1024, 1024], hero: [1376, 768], editorial: [1200, 896] };
async function req(ctx, path, body, timeoutMs = 45_000) {
const res = await fetch(API + path, {
method: "POST",
headers: {
Authorization: `Bearer ${ctx.token}`,
"wix-site-id": ctx.siteId,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`POST ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
return json;
}
/**
* Generate one image; returns its short-lived URL (import it immediately). Tries each model
* once — a per-model failure (bad params, 5xx, credit exhaustion, timeout) falls through to
* the next; throws only after all models failed.
* docs: no public reference page for /runwareschemaless/v1/request; the request shape is the one below, verified live
*/
export async function generateImage(ctx, prompt, { width = 1024, height = 1024 } = {}) {
let lastErr;
for (const model of MODELS) {
try {
const r = await req(ctx, "/runwareschemaless/v1/request", [
{
taskType: "imageInference",
taskUUID: randomUUID(), // must be a real UUIDv4 — slugs 400
outputType: "URL",
outputFormat: "PNG",
positivePrompt: prompt,
width,
height,
model,
numberResults: 1,
},
]);
const url = r?.data?.[0]?.imageURL;
if (url) return url;
lastErr = new Error(`no imageURL in response: ${JSON.stringify(r).slice(0, 200)}`);
} catch (e) {
lastErr = e;
}
}
throw lastErr;
}
const MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif" };
/**
* Upload a LOCAL file (a path on this machine — the user's own asset) into Wix Media;
* returns { id, url } (permanent). Two steps per the Upload API: generate-upload-url, then
* PUT the bytes to it — the PUT response carries the file descriptor.
* docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/generate-file-upload-url.md
*/
export async function uploadImage(ctx, path, displayName) {
const ext = extname(path).toLowerCase();
const mimeType = MIME[ext];
if (!mimeType) throw new Error(`unsupported image extension: ${path}`);
const bytes = readFileSync(path); // throws loud on a wrong path (caught per-item by resolveItemImages)
// fileName's extension MUST match the real file type — a mismatch (slug.png for a .jpg) is
// rejected; keep the caller's display name, swap in the file's own extension.
const fileName = (displayName ?? basename(path)).replace(/\.[a-z0-9]+$/i, "") + ext;
const { uploadUrl } = await req(ctx, "/site-media/v1/files/generate-upload-url", {
mimeType,
fileName,
});
const res = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: bytes,
signal: AbortSignal.timeout(120_000),
});
const json = await res.json().catch(() => ({}));
const f = json.file || json;
if (!res.ok || !f?.id) throw new Error(`upload failed (${res.status}): ${JSON.stringify(json).slice(0, 200)}`);
return { id: f.id, url: f.url };
}
/** Import an external/generated URL into Wix Media; returns { id, url } (permanent). */
// docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/import-file.md
/**
* A file this seed already put in the Media Manager under this display name, or null. The seed
* names a file after its product/choice AND a hash of its source, so a re-run finds the same
* file instead of importing (or generating, a credit each) again — and the product gallery can
* recognise it by name instead of growing a duplicate per run.
*/
// docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/search-files.md
async function findExisting(ctx, displayName) {
try {
const r = await req(ctx, "/site-media/v1/files/search", { search: displayName, rootFolder: "MEDIA_ROOT", mediaTypes: ["IMAGE"], paging: { limit: 20 } });
const f = (r.files ?? []).find((x) => x.displayName === displayName && x.url && x.operationStatus !== "FAILED");
return f ? { id: f.id, url: f.url, reused: true } : null;
} catch {
return null;
}
}
/** Short stable fingerprint of an image's source (url, prompt + size, or file bytes). */
function sourceHash(s) {
const h = createHash("sha1");
if (s.path) {
try { h.update(readFileSync(s.path)); } catch { h.update(String(s.path)); }
} else h.update(s.url ?? `${s.prompt}|${s.width ?? ""}x${s.height ?? ""}`);
return h.digest("hex").slice(0, 8);
}
/** `<name>-<hash>.<ext>`: the display name the seed gives a file, stable across runs. */
function stableName(s) {
const given = s.displayName ?? (s.path ? basename(s.path) : "image.png");
const ext = (given.match(/\.[a-z0-9]+$/i) ?? [".png"])[0];
return `${given.replace(/\.[a-z0-9]+$/i, "")}-${sourceHash(s)}${ext}`;
}
export async function importImage(ctx, url, displayName = "image.png") {
const r = await req(ctx, "/site-media/v1/files/import", { url, mimeType: "image/png", displayName });
const f = r.file || r;
if (!f?.id) throw new Error(`import-file returned no file id: ${JSON.stringify(r).slice(0, 200)}`);
return { id: f.id, url: f.url };
}
/**
* THE seed entry point. Resolves a batch of image specs to Wix Media files in ONE parallel
* wave. Each spec: { path } (LOCAL file — the user's own asset, uploaded) OR { url }
* (verified external URL — imported) OR { prompt } (generated, ~1 credit) — plus optional
* displayName, width, height. Returns an array aligned with the input: { file, error } per
* spec — `file` is { id, url } on success and null otherwise; `error` is the reason it is null
* (the thrown message, "empty spec", or "timed out"). Never throws. Every failure is also
* printed to stderr, so a seed log names the image and the reason even when the seed's
* own report does not.
*/
export async function resolveItemImagesDetailed(ctx, specs, { perImageBudgetMs = 120_000 } = {}) {
// unref: the budget timer must never keep the seed process alive after the work is done —
// a lingering timer delays the seed's exit (and the run's .seed-exit marker) by the budget.
const deadline = new Promise((r) => {
const t = setTimeout(() => r(null), perImageBudgetMs);
t.unref?.();
});
const results = await Promise.allSettled(
(specs ?? []).map(async (s) => {
if (!s || (!s.path && !s.url && !s.prompt)) return null;
const resolve = (async () => {
const name = stableName(s);
// uploadImage swaps in the file's own extension; look for what it will actually be named
const uploadedName = s.path ? name.replace(/\.[a-z0-9]+$/i, "") + extname(s.path).toLowerCase() : name;
const existing = await findExisting(ctx, s.path ? uploadedName : name);
if (existing) return existing;
if (s.path) return uploadImage(ctx, s.path, name);
const source = s.url ?? (await generateImage(ctx, s.prompt, { width: s.width, height: s.height }));
return importImage(ctx, source, name);
})();
// Hard per-image budget: even a pathological multi-model hang costs the seed at most
// perImageBudgetMs of wall clock (the wave is parallel, so it's paid once, not per item).
return Promise.race([resolve, deadline]);
}),
);
return results.map((r, i) => {
const spec = specs?.[i] ?? {};
const label = spec.displayName ?? spec.path ?? spec.url ?? (spec.prompt ? "(prompt)" : "(empty)");
if (r.status === "fulfilled") {
if (r.value) return { file: r.value, error: null };
const error = !spec.path && !spec.url && !spec.prompt ? "empty spec" : `timed out after ${perImageBudgetMs} ms`;
if (error !== "empty spec") console.error(`image ${label}: ${error}`);
return { file: null, error };
}
const error = r.reason?.message ?? String(r.reason);
console.error(`image ${label}: ${error.slice(0, 200)}`);
return { file: null, error };
});
}
/** The plain array of { id, url } | null the seeds consume today; failures are still printed. */
export async function resolveItemImages(ctx, specs, opts) {
return (await resolveItemImagesDetailed(ctx, specs, opts)).map((r) => r.file);
}
SHA-256: 02ef157995530040dec3690b255d0ee65263c889b8d7666936e77cc7be27db03