pixelkiln 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROVIDERS.md +1 -1
- package/dist/cli.js +162 -33
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +162 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -3
- package/dist/index.d.ts +27 -3
- package/dist/index.js +162 -33
- package/dist/index.js.map +1 -1
- package/docs/AGENTS.md +2 -1
- package/docs/ARCHITECTURE.md +11 -3
- package/docs/CLI.md +9 -3
- package/docs/COMFYUI.md +8 -4
- package/docs/ENDPOINTS.md +1 -1
- package/docs/GENERATORS.md +4 -2
- package/docs/LIBRARY.md +10 -2
- package/docs/RECOVERY.md +10 -1
- package/docs/REVISIONS.md +2 -2
- package/package.json +1 -1
package/PROVIDERS.md
CHANGED
|
@@ -85,7 +85,7 @@ prompt, size, batch, and optional seed inputs PixelKiln may replace.
|
|
|
85
85
|
| PixelKiln generator | `map` stills and atomic `frames` sets |
|
|
86
86
|
| Output | One PNG output node: 1–16 map candidates or 2–64 ordered still frames |
|
|
87
87
|
| Revision input | `image-to-image`, `inpaint`, and user-authored `outpaint` graphs; hashed parent/mask uploads and source comparison are covered. The bundled img2img graph passed a three-strength live smoke but missed the requested winter treatment and lost alpha. |
|
|
88
|
-
| Cost model |
|
|
88
|
+
| Cost model | No provider charge; local compute and hosting are outside PixelKiln's estimate |
|
|
89
89
|
| Reproducibility | Workflow content is hashed; model files, custom-node versions, and runtime settings must still be managed outside PixelKiln |
|
|
90
90
|
| Account lifecycle | Read-only connectivity check; no balance, remote object listing, tagging, or purge |
|
|
91
91
|
| Confidence | Live map generation, four-candidate review, cache-only restore, and refinement on Apple MPS; deterministic coverage for frame submission, ordered review/fetch, shared-grid and palette gating, approval, and packing |
|
package/dist/cli.js
CHANGED
|
@@ -919,7 +919,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
|
|
|
919
919
|
rateLimit() {
|
|
920
920
|
return { spacingMs: 0, maxInFlight: 1 };
|
|
921
921
|
}
|
|
922
|
-
async submit(spec, styleImages) {
|
|
922
|
+
async submit(spec, styleImages, context) {
|
|
923
923
|
this.validate(spec, styleImages);
|
|
924
924
|
const options = resolvedOptions(spec);
|
|
925
925
|
const uploadedImages = /* @__PURE__ */ new Map();
|
|
@@ -961,22 +961,35 @@ var ComfyUIProvider = class _ComfyUIProvider {
|
|
|
961
961
|
if (spec.generator === "frames") {
|
|
962
962
|
const frames = options.frames;
|
|
963
963
|
const values = spec.providerInputs[frames.vary];
|
|
964
|
-
|
|
965
|
-
|
|
964
|
+
let promptIds = [];
|
|
965
|
+
if (context?.previousJobId) {
|
|
966
|
+
const previous = decodeFrameSetJob(context.previousJobId);
|
|
967
|
+
if (previous.outputNodeId !== options.outputNodeId || previous.expectedCount !== values.length) {
|
|
968
|
+
throw new Error("Saved ComfyUI frame checkpoint does not match the current frame set");
|
|
969
|
+
}
|
|
970
|
+
promptIds = [...previous.promptIds];
|
|
971
|
+
}
|
|
972
|
+
for (let index = promptIds.length; index < values.length; index++) {
|
|
966
973
|
promptIds.push(await this.client.submit(await buildWorkflow(index)));
|
|
974
|
+
const frameJob2 = {
|
|
975
|
+
expectedCount: values.length,
|
|
976
|
+
promptIds: [...promptIds],
|
|
977
|
+
outputNodeId: options.outputNodeId
|
|
978
|
+
};
|
|
979
|
+
await context?.checkpoint({
|
|
980
|
+
jobId: encodeFrameSetJob(frameJob2),
|
|
981
|
+
metadata: comfyFrameSubmissionMetadata(spec, frameJob2),
|
|
982
|
+
complete: promptIds.length === values.length
|
|
983
|
+
});
|
|
967
984
|
}
|
|
985
|
+
const frameJob = {
|
|
986
|
+
expectedCount: values.length,
|
|
987
|
+
promptIds,
|
|
988
|
+
outputNodeId: options.outputNodeId
|
|
989
|
+
};
|
|
968
990
|
return {
|
|
969
|
-
jobId: encodeFrameSetJob(
|
|
970
|
-
metadata:
|
|
971
|
-
inputs,
|
|
972
|
-
frameSet: {
|
|
973
|
-
count: values.length,
|
|
974
|
-
vary: frames.vary,
|
|
975
|
-
seedStep: frames.seedStep,
|
|
976
|
-
fps: spec.quality?.fps ?? 12,
|
|
977
|
-
promptIds
|
|
978
|
-
}
|
|
979
|
-
}
|
|
991
|
+
jobId: encodeFrameSetJob(frameJob),
|
|
992
|
+
metadata: comfyFrameSubmissionMetadata(spec, frameJob)
|
|
980
993
|
};
|
|
981
994
|
}
|
|
982
995
|
const workflow = await buildWorkflow();
|
|
@@ -1033,6 +1046,9 @@ var ComfyUIProvider = class _ComfyUIProvider {
|
|
|
1033
1046
|
async poll(jobId, generator, context) {
|
|
1034
1047
|
if (generator === "frames") {
|
|
1035
1048
|
const frameJob = decodeFrameSetJob(jobId);
|
|
1049
|
+
if (frameJob.promptIds.length < frameJob.expectedCount) {
|
|
1050
|
+
return { status: "processing" };
|
|
1051
|
+
}
|
|
1036
1052
|
const images2 = [];
|
|
1037
1053
|
for (let index = 0; index < frameJob.promptIds.length; index++) {
|
|
1038
1054
|
const promptId2 = frameJob.promptIds[index];
|
|
@@ -1305,6 +1321,19 @@ function comfyFrameMetadata(spec, job, images, fps) {
|
|
|
1305
1321
|
}
|
|
1306
1322
|
};
|
|
1307
1323
|
}
|
|
1324
|
+
function comfyFrameSubmissionMetadata(spec, job) {
|
|
1325
|
+
const options = resolvedOptions(spec);
|
|
1326
|
+
return {
|
|
1327
|
+
inputs: providerInputProvenance(spec.providerInputs),
|
|
1328
|
+
frameSet: {
|
|
1329
|
+
count: job.expectedCount,
|
|
1330
|
+
vary: options.frames.vary,
|
|
1331
|
+
seedStep: options.frames.seedStep,
|
|
1332
|
+
fps: spec.quality?.fps ?? 12,
|
|
1333
|
+
promptIds: job.promptIds
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1308
1337
|
function isImageBinding(workflow, binding) {
|
|
1309
1338
|
const node = workflow[binding.nodeId];
|
|
1310
1339
|
return binding.input === "image" && (node?.class_type === "LoadImage" || node?.class_type === "LoadImageMask");
|
|
@@ -1360,10 +1389,18 @@ function decodeFrameSetJob(jobId) {
|
|
|
1360
1389
|
} catch {
|
|
1361
1390
|
throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
|
|
1362
1391
|
}
|
|
1363
|
-
if (!isObject(value) ||
|
|
1392
|
+
if (!isObject(value) || !Array.isArray(value.promptIds)) {
|
|
1364
1393
|
throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
|
|
1365
1394
|
}
|
|
1366
|
-
|
|
1395
|
+
const expectedCount = value.expectedCount === void 0 ? value.promptIds.length : value.expectedCount;
|
|
1396
|
+
if (typeof value.outputNodeId !== "string" || !value.outputNodeId || typeof expectedCount !== "number" || !Number.isSafeInteger(expectedCount) || expectedCount < 2 || expectedCount > 64 || value.promptIds.length < 1 || value.promptIds.length > expectedCount || !value.promptIds.every((id) => typeof id === "string" && id.length > 0)) {
|
|
1397
|
+
throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
expectedCount,
|
|
1401
|
+
outputNodeId: value.outputNodeId,
|
|
1402
|
+
promptIds: value.promptIds
|
|
1403
|
+
};
|
|
1367
1404
|
}
|
|
1368
1405
|
function decodeJob(jobId) {
|
|
1369
1406
|
const split = jobId.lastIndexOf("#");
|
|
@@ -2146,6 +2183,8 @@ var LockEntrySchema = z2.object({
|
|
|
2146
2183
|
).optional(),
|
|
2147
2184
|
/** Set at submit time, before the request is awaited, so a crash is recoverable. */
|
|
2148
2185
|
jobId: z2.string().nullable().default(null),
|
|
2186
|
+
/** False only when a provider checkpoint says more requests remain. */
|
|
2187
|
+
submissionComplete: z2.boolean().optional(),
|
|
2149
2188
|
/** For `1dir`: the multi-candidate parent object awaiting selection. */
|
|
2150
2189
|
reviewObjectId: z2.string().nullable().default(null),
|
|
2151
2190
|
/** The chosen candidate's own object id, once selected. */
|
|
@@ -5916,6 +5955,7 @@ function resumeActions(specs, lock) {
|
|
|
5916
5955
|
const key = lockKey(spec.styleId, spec.assetId);
|
|
5917
5956
|
const entry = lock.entries[key];
|
|
5918
5957
|
if (!entry || entry.specHash !== spec.specHash) continue;
|
|
5958
|
+
if (entry.submissionComplete === false) continue;
|
|
5919
5959
|
const command = resumeCommandForStatus(entry.status);
|
|
5920
5960
|
if (!command) continue;
|
|
5921
5961
|
if (command === "poll" && !entry.jobId) continue;
|
|
@@ -5966,10 +6006,13 @@ async function buildPlan(specs, lock, opts = {}) {
|
|
|
5966
6006
|
reason = `${entry.error ?? "download failed"}; run pixelkiln fetch${force} (no generation cost)`;
|
|
5967
6007
|
} else if (entry.status === "failed") {
|
|
5968
6008
|
state = "failed";
|
|
5969
|
-
reason = entry.error ?? "previous attempt failed";
|
|
6009
|
+
reason = entry.jobId && entry.submissionComplete === false ? `${entry.error ?? "submission interrupted"}; saved checkpoint will resume` : entry.error ?? "previous attempt failed";
|
|
5970
6010
|
} else if (entry.status === "selected") {
|
|
5971
6011
|
state = "recoverable";
|
|
5972
6012
|
reason = "provider output is selected; run pixelkiln fetch (no generation cost)";
|
|
6013
|
+
} else if ((entry.status === "pending" || entry.status === "processing") && entry.jobId && entry.submissionComplete === false) {
|
|
6014
|
+
state = "failed";
|
|
6015
|
+
reason = "submission stopped after a saved checkpoint; rerun pixelkiln submit to resume";
|
|
5973
6016
|
} else if (entry.status === "pending" || entry.status === "processing") {
|
|
5974
6017
|
state = "in-flight";
|
|
5975
6018
|
reason = entry.jobId ? "awaiting processing; run pixelkiln poll" : "submission state has no job id; run pixelkiln doctor before retrying";
|
|
@@ -6113,6 +6156,11 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
6113
6156
|
if (since < spacing) await sleep2(spacing - since);
|
|
6114
6157
|
await requireRevisionReady(spec, lock);
|
|
6115
6158
|
const previousEntry = lock.entries[key];
|
|
6159
|
+
const resumesCheckpoint = Boolean(
|
|
6160
|
+
previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
|
|
6161
|
+
);
|
|
6162
|
+
const previousJobId = resumesCheckpoint ? previousEntry.jobId : void 0;
|
|
6163
|
+
const previousMetadata = resumesCheckpoint ? previousEntry.providerMetadata[provider.id] : void 0;
|
|
6116
6164
|
const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
|
|
6117
6165
|
upsert(lock, key, {
|
|
6118
6166
|
styleId: spec.styleId,
|
|
@@ -6131,7 +6179,8 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
6131
6179
|
...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
|
|
6132
6180
|
} : null,
|
|
6133
6181
|
status: "pending",
|
|
6134
|
-
jobId: null,
|
|
6182
|
+
jobId: previousJobId ?? null,
|
|
6183
|
+
...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
|
|
6135
6184
|
objectId: null,
|
|
6136
6185
|
reviewObjectId: null,
|
|
6137
6186
|
candidateIndex: null,
|
|
@@ -6140,10 +6189,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
6140
6189
|
// Keep the old ownership proof while new bytes are pending. Fetch may
|
|
6141
6190
|
// replace that file only while its hash still matches this record.
|
|
6142
6191
|
supersededOutputs,
|
|
6143
|
-
providerMetadata: {},
|
|
6192
|
+
providerMetadata: resumesCheckpoint ? previousEntry.providerMetadata : {},
|
|
6144
6193
|
sourceUrl: null,
|
|
6145
6194
|
sourceUrls: [],
|
|
6146
|
-
submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6195
|
+
submittedAt: resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
6147
6196
|
cost: estimate.amount,
|
|
6148
6197
|
costUnit: estimate.unit,
|
|
6149
6198
|
// Persist routing before the request starts. If the process stops while
|
|
@@ -6156,9 +6205,32 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
6156
6205
|
lastSubmitAt = Date.now();
|
|
6157
6206
|
try {
|
|
6158
6207
|
const refs = styleImages.get(spec.styleId) ?? [];
|
|
6159
|
-
const { jobId, metadata } = await provider.submit(spec, refs
|
|
6208
|
+
const { jobId, metadata } = await provider.submit(spec, refs, {
|
|
6209
|
+
...previousJobId ? { previousJobId } : {},
|
|
6210
|
+
...previousMetadata ? { previousMetadata } : {},
|
|
6211
|
+
checkpoint: async (checkpoint) => {
|
|
6212
|
+
if (!checkpoint.jobId) throw new Error("Provider checkpoint returned an empty job id");
|
|
6213
|
+
const entry = lock.entries[key];
|
|
6214
|
+
upsert(lock, key, {
|
|
6215
|
+
jobId: checkpoint.jobId,
|
|
6216
|
+
submissionComplete: checkpoint.complete,
|
|
6217
|
+
reviewObjectId: checkpoint.complete && (spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature) ? checkpoint.jobId : null,
|
|
6218
|
+
status: checkpoint.complete ? "processing" : "pending",
|
|
6219
|
+
error: null,
|
|
6220
|
+
providerMetadata: checkpoint.metadata ? {
|
|
6221
|
+
...entry.providerMetadata,
|
|
6222
|
+
[provider.id]: {
|
|
6223
|
+
...entry.providerMetadata[provider.id],
|
|
6224
|
+
...checkpoint.metadata
|
|
6225
|
+
}
|
|
6226
|
+
} : entry.providerMetadata
|
|
6227
|
+
});
|
|
6228
|
+
await saveLock(lockPath, lock);
|
|
6229
|
+
}
|
|
6230
|
+
});
|
|
6160
6231
|
upsert(lock, key, {
|
|
6161
6232
|
jobId,
|
|
6233
|
+
submissionComplete: true,
|
|
6162
6234
|
// A multi-candidate generator routes through review; record the parent
|
|
6163
6235
|
// so `pick` knows where to look.
|
|
6164
6236
|
reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
|
|
@@ -6172,10 +6244,23 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
6172
6244
|
` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
|
|
6173
6245
|
);
|
|
6174
6246
|
} catch (err) {
|
|
6175
|
-
failed++;
|
|
6176
6247
|
const message5 = err instanceof Error ? err.message : String(err);
|
|
6177
|
-
|
|
6178
|
-
|
|
6248
|
+
const entry = lock.entries[key];
|
|
6249
|
+
if (entry.jobId && entry.submissionComplete === true) {
|
|
6250
|
+
upsert(lock, key, { status: "processing", error: null });
|
|
6251
|
+
inFlight.set(entry.jobId, spec);
|
|
6252
|
+
submitted++;
|
|
6253
|
+
spent += estimate.amount;
|
|
6254
|
+
log2(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
|
|
6255
|
+
} else {
|
|
6256
|
+
failed++;
|
|
6257
|
+
upsert(lock, key, {
|
|
6258
|
+
status: "failed",
|
|
6259
|
+
error: message5,
|
|
6260
|
+
cost: entry.jobId ? estimate.amount : 0
|
|
6261
|
+
});
|
|
6262
|
+
log2(` FAILED ${key}: ${message5}`);
|
|
6263
|
+
}
|
|
6179
6264
|
}
|
|
6180
6265
|
await saveLock(lockPath, lock);
|
|
6181
6266
|
}
|
|
@@ -6193,12 +6278,19 @@ async function poll(provider, lock, lockPath, opts = {}) {
|
|
|
6193
6278
|
const specByKey = new Map((opts.specs ?? []).map((s) => [lockKey(s.styleId, s.assetId), s]));
|
|
6194
6279
|
const selectedKeys = opts.specs ? new Set(specByKey.keys()) : null;
|
|
6195
6280
|
const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
|
|
6281
|
+
const incompleteSubmissions = Object.entries(lock.entries).filter(
|
|
6282
|
+
([key, entry]) => entry.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && entry.jobId && entry.submissionComplete === false && (entry.status === "pending" || entry.status === "processing")
|
|
6283
|
+
);
|
|
6284
|
+
for (const [key] of incompleteSubmissions) {
|
|
6285
|
+
log2(` incomplete submission ${key}; rerun \`pixelkiln submit\` to resume`);
|
|
6286
|
+
}
|
|
6287
|
+
result.stillRunning = incompleteSubmissions.length;
|
|
6196
6288
|
const pending = () => Object.entries(lock.entries).filter(
|
|
6197
|
-
([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && (e.status === "pending" || e.status === "processing")
|
|
6289
|
+
([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && e.submissionComplete !== false && (e.status === "pending" || e.status === "processing")
|
|
6198
6290
|
);
|
|
6199
6291
|
while (pending().length > 0) {
|
|
6200
6292
|
if (Date.now() - started > timeout) {
|
|
6201
|
-
result.stillRunning
|
|
6293
|
+
result.stillRunning += pending().length;
|
|
6202
6294
|
log2(` timed out with ${result.stillRunning} job(s) still running \u2014 re-run \`poll\` to resume`);
|
|
6203
6295
|
break;
|
|
6204
6296
|
}
|
|
@@ -6986,7 +7078,10 @@ function renderSheet(groups) {
|
|
|
6986
7078
|
.loop { display:flex; align-items:flex-start; gap:12px; margin-bottom:12px; }
|
|
6987
7079
|
.loop img { image-rendering:pixelated; border:2px solid var(--accent); padding:7px;
|
|
6988
7080
|
background:var(--panel-deep); max-width:min(var(--preview),calc(100vw - 62px)); height:auto; }
|
|
6989
|
-
.loop-copy { color:var(--dim); font-size:11px; max-width:32ch;
|
|
7081
|
+
.loop-copy { color:var(--dim); font-size:11px; max-width:32ch; display:flex;
|
|
7082
|
+
flex-direction:column; gap:8px; }
|
|
7083
|
+
.loop-copy p { margin:0; }
|
|
7084
|
+
.loop-copy button { align-self:flex-start; padding:5px 9px; font-size:10.5px; }
|
|
6990
7085
|
.cand { border:2px solid var(--line-strong); border-radius:0; padding:7px; background:var(--panel-deep);
|
|
6991
7086
|
cursor:pointer; display:flex; flex:0 0 auto; flex-direction:column; align-items:center; gap:5px;
|
|
6992
7087
|
position:relative; max-width:100%; }
|
|
@@ -7090,15 +7185,49 @@ GROUPS.forEach((g, gi) => {
|
|
|
7090
7185
|
preview.alt = 'Animated frame-set preview';
|
|
7091
7186
|
const copy = document.createElement('div');
|
|
7092
7187
|
copy.className = 'loop-copy';
|
|
7093
|
-
|
|
7188
|
+
const copyText = document.createElement('p');
|
|
7189
|
+
copyText.textContent = g.frameUrls.length + ' ordered frames \xB7 ' + (g.fps || 12) +
|
|
7094
7190
|
' fps. Accepting keeps every frame; one bad frame means leave the set unchosen.';
|
|
7191
|
+
const playback = document.createElement('button');
|
|
7192
|
+
playback.type = 'button';
|
|
7193
|
+
let userPaused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
7194
|
+
let loopVisible = true;
|
|
7195
|
+
let pageVisible = !document.hidden;
|
|
7196
|
+
let loopTimer = null;
|
|
7197
|
+
let loopIndex = 0;
|
|
7198
|
+
const stopLoop = () => {
|
|
7199
|
+
if (loopTimer !== null) window.clearInterval(loopTimer);
|
|
7200
|
+
loopTimer = null;
|
|
7201
|
+
};
|
|
7202
|
+
const syncLoop = () => {
|
|
7203
|
+
stopLoop();
|
|
7204
|
+
playback.textContent = userPaused ? 'Play preview' : 'Pause preview';
|
|
7205
|
+
playback.setAttribute('aria-pressed', String(userPaused));
|
|
7206
|
+
if (!userPaused && loopVisible && pageVisible) {
|
|
7207
|
+
loopTimer = window.setInterval(() => {
|
|
7208
|
+
loopIndex = (loopIndex + 1) % g.frameUrls.length;
|
|
7209
|
+
preview.src = g.frameUrls[loopIndex];
|
|
7210
|
+
}, Math.max(16, Math.round(1000 / (g.fps || 12))));
|
|
7211
|
+
}
|
|
7212
|
+
};
|
|
7213
|
+
playback.addEventListener('click', () => {
|
|
7214
|
+
userPaused = !userPaused;
|
|
7215
|
+
syncLoop();
|
|
7216
|
+
});
|
|
7217
|
+
copy.append(copyText, playback);
|
|
7095
7218
|
loop.append(preview, copy);
|
|
7096
7219
|
review.insertBefore(loop, frames);
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7220
|
+
document.addEventListener('visibilitychange', () => {
|
|
7221
|
+
pageVisible = !document.hidden;
|
|
7222
|
+
syncLoop();
|
|
7223
|
+
});
|
|
7224
|
+
syncLoop();
|
|
7225
|
+
if ('IntersectionObserver' in window) {
|
|
7226
|
+
new IntersectionObserver((entries) => {
|
|
7227
|
+
loopVisible = entries[0]?.isIntersecting ?? false;
|
|
7228
|
+
syncLoop();
|
|
7229
|
+
}).observe(loop);
|
|
7230
|
+
}
|
|
7102
7231
|
}
|
|
7103
7232
|
g.frameUrls.forEach((url, i) => {
|
|
7104
7233
|
const c = document.createElement('button');
|