Setting the file. One moment.
Images · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page
16.761 shared/seed/ images.mjs
JavaScript · 239 lines · 12 KB
from
"node:crypto"
;
14 import { readFileSync } from "node:fs" ;
15 import { basename, extname } from "node:path" ;
16 import { createHash } from "node:crypto" ;
17
18 const API = "https://www.wixapis.com" ;
19 // Order = cheap-and-permissive first (runware ~0.009 credits/img, ~5s, loosest content
20 // filter), then google (best fidelity, ~0.14, ~25s; rejects steps/CFGScale and free-form
21 // sizes), then bfl (strictest filter — refuses trademark-ish prompts). A refusal or failure
22 // falls through to the next model.
23 const MODELS = [ "runware:400@1" , "google:4@2" , "bfl:5@1" ];
24 /** Allowed dimensions: 1024×1024 (square — entities), 1376×768 (16:9 hero), 1200×896 (4:3). */
25 export const IMAGE_SIZES = { square: [ 1024 , 1024 ], hero: [ 1376 , 768 ], editorial: [ 1200 , 896 ] };
26
27 async function req ( ctx , path , body , timeoutMs = 45_000 ) {
28 const res = await fetch ( API + path, {
29 method: "POST" ,
30 headers: {
31 Authorization: `Bearer ${ ctx . token }` ,
32 "wix-site-id" : ctx.siteId,
33 "Content-Type" : "application/json" ,
34 },
35 body: JSON . stringify (body),
36 signal: AbortSignal. timeout (timeoutMs),
37 });
38 const json = await res. json (). catch (() => ({}));
39 if ( ! res.ok) throw new Error ( `POST ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 300 ) }` );
40 return json;
41 }
42
43 /**
44 * Generate one image; returns its short-lived URL (import it immediately). Tries each model
45 * once — a per-model failure (bad params, 5xx, credit exhaustion, timeout) falls through to
46 * the next; throws only after all models failed.
47 * docs: no public reference page for /runwareschemaless/v1/request; the request shape is the one below, verified live
48 */
49 export async function generateImage ( ctx , prompt , { width = 1024 , height = 1024 } = {}) {
50 let lastErr;
51 for ( const model of MODELS ) {
52 try {
53 const r = await req (ctx, "/runwareschemaless/v1/request" , [
54 {
55 taskType: "imageInference" ,
56 taskUUID: randomUUID (), // must be a real UUIDv4 — slugs 400
57 outputType: "URL" ,
58 outputFormat: "PNG" ,
59 positivePrompt: prompt,
60 width,
61 height,
62 model,
63 numberResults: 1 ,
64 },
65 ]);
66 const url = r?.data?.[ 0 ]?.imageURL;
67 if (url) return url;
68 lastErr = new Error ( `no imageURL in response: ${ JSON . stringify ( r ). slice ( 0 , 200 ) }` );
69 } catch (e) {
70 lastErr = e;
71 }
72 }
73 throw lastErr;
74 }
75
76 const MIME = { ".png" : "image/png" , ".jpg" : "image/jpeg" , ".jpeg" : "image/jpeg" , ".webp" : "image/webp" , ".gif" : "image/gif" , ".avif" : "image/avif" };
77
78 /**
79 * Upload a LOCAL file (a path on this machine — the user's own asset) into Wix Media;
80 * returns { id, url } (permanent). Two steps per the Upload API: generate-upload-url, then
81 * PUT the bytes to it — the PUT response carries the file descriptor.
82 * docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/generate-file-upload-url.md
83 */
84 export async function uploadImage ( ctx , path , displayName ) {
85 const ext = extname (path). toLowerCase ();
86 const mimeType = MIME [ext];
87 if ( ! mimeType) throw new Error ( `unsupported image extension: ${ path }` );
88 const bytes = readFileSync (path); // throws loud on a wrong path (caught per-item by resolveItemImages)
89 // fileName's extension MUST match the real file type — a mismatch (slug.png for a .jpg) is
90 // rejected; keep the caller's display name, swap in the file's own extension.
91 const fileName = (displayName ?? basename (path)). replace ( / \. [a-z0-9] +$ / i , "" ) + ext;
92 const { uploadUrl } = await req (ctx, "/site-media/v1/files/generate-upload-url" , {
93 mimeType,
94 fileName,
95 });
96 const res = await fetch (uploadUrl, {
97 method: "PUT" ,
98 headers: { "Content-Type" : mimeType },
99 body: bytes,
100 signal: AbortSignal. timeout ( 120_000 ),
101 });
102 const json = await res. json (). catch (() => ({}));
103 const f = json.file || json;
104 if ( ! res.ok || ! f?.id) throw new Error ( `upload failed (${ res . status }): ${ JSON . stringify ( json ). slice ( 0 , 200 ) }` );
105 return { id: f.id, url: f.url };
106 }
107
108 /** Import an external/generated URL into Wix Media; returns { id, url } (permanent). */
109 // docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/import-file.md
110 /**
111 * A file this seed already put in the Media Manager under this display name, or null. The seed
112 * names a file after its product/choice AND a hash of its source, so a re-run finds the same
113 * file instead of importing (or generating, a credit each) again — and the product gallery can
114 * recognise it by name instead of growing a duplicate per run.
115 */
116 // docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/search-files.md
117 async function findExisting ( ctx , displayName ) {
118 try {
119 const r = await req (ctx, "/site-media/v1/files/search" , { search: displayName, rootFolder: "MEDIA_ROOT" , mediaTypes: [ "IMAGE" ], paging: { limit: 20 } });
120 const f = (r.files ?? []). find (( x ) => x.displayName === displayName && x.url && x.operationStatus !== "FAILED" );
121 return f ? { id: f.id, url: f.url, reused: true } : null ;
122 } catch {
123 return null ;
124 }
125 }
126
127 /**
128 * The Media Manager file a spec names, when it names one: `mediaId`, or the file id at the end of a
129 * `static.wixstatic.com/media/<fileId>` URL. Returns { id, url, existing: true } for a file on this
130 * site, waiting out a short PENDING (an upload still processing), or null when the URL's file is
131 * not on this site (it is then imported like any external URL). An explicit `mediaId` that is not
132 * on the site throws, so the product is reported text-only with the reason.
133 */
134 // docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/get-file-descriptor.md
135 const WIX_MEDIA_URL = / ^ https ? : \/\/ static \. wixstatic \. com \/ media \/ ( [ ^ /?#] + )/ ;
136 async function existingMediaFile ( ctx , s ) {
137 const id = s.mediaId ?? s.url?. match ( WIX_MEDIA_URL )?.[ 1 ];
138 if ( ! id) return null ;
139 for ( let attempt = 0 ; attempt < 10 ; attempt ++ ) {
140 const res = await fetch ( `${ API }/site-media/v1/files/get-file-by-id?fileId=${ encodeURIComponent ( id ) }` , {
141 headers: { Authorization: `Bearer ${ ctx . token }` , "wix-site-id" : ctx.siteId },
142 signal: AbortSignal. timeout ( 20_000 ),
143 });
144 if (res.status === 404 ) {
145 if (s.mediaId) throw new Error ( `no Media Manager file ${ id } on this site` );
146 return null ;
147 }
148 const json = await res. json (). catch (() => ({}));
149 if ( ! res.ok) throw new Error ( `GET get-file-by-id -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 200 ) }` );
150 const f = json.file;
151 if (f?.operationStatus === "FAILED" ) throw new Error ( `Media Manager file ${ id } failed processing` );
152 if (f?.url && f.operationStatus !== "PENDING" ) return { id: f.id, url: f.url, existing: true };
153 await new Promise (( r ) => setTimeout (r, 3000 ));
154 }
155 throw new Error ( `Media Manager file ${ id } still processing` );
156 }
157
158 /** Short stable fingerprint of an image's source (url, prompt + size, or file bytes). */
159 function sourceHash ( s ) {
160 const h = createHash ( "sha1" );
161 if (s.path) {
162 try { h. update ( readFileSync (s.path)); } catch { h. update ( String (s.path)); }
163 } else h. update (s.url ?? `${ s . prompt }|${ s . width ?? ""}x${ s . height ?? ""}` );
164 return h. digest ( "hex" ). slice ( 0 , 8 );
165 }
166
167 /** `<name>-<hash>.<ext>`: the display name the seed gives a file, stable across runs. */
168 function stableName ( s ) {
169 const given = s.displayName ?? (s.path ? basename (s.path) : "image.png" );
170 const ext = (given. match ( / \. [a-z0-9] +$ / i ) ?? [ ".png" ])[ 0 ];
171 return `${ given . replace ( / \. [a-z0-9] +$ / i , "" ) }-${ sourceHash ( s ) }${ ext }` ;
172 }
173
174 export async function importImage ( ctx , url , displayName = "image.png" ) {
175 const r = await req (ctx, "/site-media/v1/files/import" , { url, mimeType: "image/png" , displayName });
176 const f = r.file || r;
177 if ( ! f?.id) throw new Error ( `import-file returned no file id: ${ JSON . stringify ( r ). slice ( 0 , 200 ) }` );
178 return { id: f.id, url: f.url };
179 }
180
181 /**
182 * THE seed entry point. Resolves a batch of image specs to Wix Media files in ONE parallel
183 * wave. Each spec: { mediaId } (a file already in this site's Media Manager — used as it is,
184 * as is a static.wixstatic.com/media URL of one) OR { path } (LOCAL file — the user's own asset,
185 * uploaded) OR { url } (verified external URL — imported) OR { prompt } (generated, ~1 credit) —
186 * plus optional
187 * displayName, width, height. Returns an array aligned with the input: { file, error } per
188 * spec — `file` is { id, url } on success and null otherwise; `error` is the reason it is null
189 * (the thrown message, "empty spec", or "timed out"). Never throws. Every failure is also
190 * printed to stderr, so a seed log names the image and the reason even when the seed's
191 * own report does not.
192 */
193 export async function resolveItemImagesDetailed ( ctx , specs , { perImageBudgetMs = 120_000 } = {}) {
194 // unref: the budget timer must never keep the seed process alive after the work is done —
195 // a lingering timer delays the seed's exit (and the run's .seed-exit marker) by the budget.
196 const deadline = new Promise (( r ) => {
197 const t = setTimeout (() => r ( null ), perImageBudgetMs);
198 t. unref ?.();
199 });
200 const results = await Promise . allSettled (
201 (specs ?? []). map ( async ( s ) => {
202 if ( ! s || ( ! s.path && ! s.url && ! s.prompt && ! s.mediaId)) return null ;
203 const resolve = ( async () => {
204 // A file already in this site's Media Manager is used as it is: no import, no copy.
205 const inMedia = await existingMediaFile (ctx, s);
206 if (inMedia) return inMedia;
207 const name = stableName (s);
208 // uploadImage swaps in the file's own extension; look for what it will actually be named
209 const uploadedName = s.path ? name. replace ( / \. [a-z0-9] +$ / i , "" ) + extname (s.path). toLowerCase () : name;
210 const existing = await findExisting (ctx, s.path ? uploadedName : name);
211 if (existing) return existing;
212 if (s.path) return uploadImage (ctx, s.path, name);
213 const source = s.url ?? ( await generateImage (ctx, s.prompt, { width: s.width, height: s.height }));
214 return importImage (ctx, source, name);
215 })();
216 // Hard per-image budget: even a pathological multi-model hang costs the seed at most
217 // perImageBudgetMs of wall clock (the wave is parallel, so it's paid once, not per item).
218 return Promise . race ([resolve, deadline]);
219 }),
220 );
221 return results. map (( r , i ) => {
222 const spec = specs?.[i] ?? {};
223 const label = spec.displayName ?? spec.mediaId ?? spec.path ?? spec.url ?? (spec.prompt ? "(prompt)" : "(empty)" );
224 if (r.status === "fulfilled" ) {
225 if (r.value) return { file: r.value, error: null };
226 const error = ! spec.path && ! spec.url && ! spec.prompt && ! spec.mediaId ? "empty spec" : `timed out after ${ perImageBudgetMs } ms` ;
227 if (error !== "empty spec" ) console. error ( `image ${ label }: ${ error }` );
228 return { file: null , error };
229 }
230 const error = r.reason?.message ?? String (r.reason);
231 console. error ( `image ${ label }: ${ error . slice ( 0 , 200 ) }` );
232 return { file: null , error };
233 });
234 }
235
236 /** The plain array of { id, url } | null the seeds consume today; failures are still printed. */
237 export async function resolveItemImages ( ctx , specs , opts ) {
238 return ( await resolveItemImagesDetailed (ctx, specs, opts)). map (( r ) => r.file);
239 }