hyperframes 0.8.22 → 0.8.24

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/dist/cli.js CHANGED
@@ -50,7 +50,7 @@ var VERSION;
50
50
  var init_version = __esm({
51
51
  "src/version.ts"() {
52
52
  "use strict";
53
- VERSION = true ? "0.8.22" : "0.0.0-dev";
53
+ VERSION = true ? "0.8.24" : "0.0.0-dev";
54
54
  }
55
55
  });
56
56
 
@@ -73411,7 +73411,22 @@ async function downloadWithRetry(url, localPath, timeoutMs, signal, onTransientR
73411
73411
  return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal);
73412
73412
  } catch (error) {
73413
73413
  const classified = classifyDownloadFailure(error);
73414
- if (!classified.locallyRetryable || attempt >= maxTransientRetries) throw classified;
73414
+ if (!classified.locallyRetryable || attempt >= maxTransientRetries) {
73415
+ const identity2 = safeDownloadUrlIdentity(url);
73416
+ throw new UrlDownloadError(
73417
+ classified.kind,
73418
+ classified.retryable,
73419
+ classified.message,
73420
+ classified.status,
73421
+ {
73422
+ ...classified.telemetry,
73423
+ urlFingerprint: identity2.urlFingerprint,
73424
+ initialHost: identity2.host,
73425
+ attempt: attempt + 1
73426
+ },
73427
+ classified.locallyRetryable
73428
+ );
73429
+ }
73415
73430
  if (classified.retryable) onTransientRetry?.(classified);
73416
73431
  const identity = safeDownloadUrlIdentity(url);
73417
73432
  emitDownloadTelemetry(options, {
@@ -84035,6 +84050,47 @@ ${other.raw}`)
84035
84050
  }
84036
84051
  return findings;
84037
84052
  },
84053
+ // gsap_timeline_return_used_as_tween — `tl.to()` returns the TIMELINE, not the tween it just
84054
+ // created. `gsap.to()` returns a Tween, so the two read identically and behave nothing alike.
84055
+ // Capturing the timeline's return and later treating it as an individual animation aims a
84056
+ // tween-scoped call at the whole composition: `.kill()` on it interrupts the master timeline
84057
+ // and detaches it from its parent, which stops a parent-driven seek dead while leaving an
84058
+ // explicit `tl.progress()` still working -- so a render of the packaged defaults looks fine
84059
+ // and only live playback breaks. A rebuild that collected these to discard them also leaves
84060
+ // every previous keyframe in place and stacks the new ones on top.
84061
+ ({ scripts }) => {
84062
+ const findings = [];
84063
+ for (const script of scripts) {
84064
+ const source = stripJsComments(script.content);
84065
+ const timelineVars = collectTimelineVarNames(source);
84066
+ if (timelineVars.length === 0) continue;
84067
+ const receivers = timelineVars.map(escapeRegExp22).join("|");
84068
+ const pattern = new RegExp(
84069
+ String.raw`(?:\.push\s*\(\s*|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*)(?:${receivers})\s*\.\s*(to|from|fromTo|set|call|add)\s*\(`,
84070
+ "g"
84071
+ );
84072
+ let match;
84073
+ while ((match = pattern.exec(source)) !== null) {
84074
+ const boundName = match[1];
84075
+ const method = match[2];
84076
+ if (boundName) {
84077
+ const treatedAsTween = new RegExp(
84078
+ String.raw`\b${escapeRegExp22(boundName)}\s*\.\s*(?:kill|revert|invalidate|pause|resume|restart|seek|progress|timeScale)\s*\(`
84079
+ );
84080
+ if (!treatedAsTween.test(source)) continue;
84081
+ }
84082
+ const contextStart = Math.max(0, match.index - 40);
84083
+ findings.push({
84084
+ code: "gsap_timeline_return_used_as_tween",
84085
+ severity: "error",
84086
+ message: `\`${match[0].includes(".push") ? "push" : boundName}\` captures the return of \`.${method}()\` on timeline \`${timelineVars[0]}\`, but a timeline's \`.to()\`/\`.from()\`/\`.set()\` returns THE TIMELINE ITSELF, not the tween it created. Every captured value is the same master timeline, so a tween-scoped call on it \u2014 \`.kill()\` above all \u2014 hits the whole composition: it interrupts the timeline and detaches it from its parent, which freezes a parent-driven seek while an explicit \`tl.progress()\` keeps working. Only \`gsap.to()\` returns a tween.`,
84087
+ fixHint: "To build a group of keyframes you can later discard, put them in a nested timeline: `const nested = gsap.timeline(); nested.to(...); tl.add(nested, 0);` \u2014 then `nested.kill()` replaces exactly those keyframes and cannot reach `tl`. Children keep their absolute times when the nest is added at 0. To hold a single real tween, create it with `gsap.to(...)` and place it with `tl.add(tween, at)`.",
84088
+ snippet: truncateSnippet(source.slice(contextStart, match.index + match[0].length + 60))
84089
+ });
84090
+ }
84091
+ }
84092
+ return findings;
84093
+ },
84038
84094
  // gsap_group_selector_keyframes
84039
84095
  ({ scripts }) => {
84040
84096
  const findings = [];
@@ -87208,6 +87264,37 @@ function extractionFrameCountForDuration(durationSeconds, fps, isVFR) {
87208
87264
  function sdrToHdrTransformKey(transfer) {
87209
87265
  return `sdr2hdr-${transfer}`;
87210
87266
  }
87267
+ function safeVideoExtractionSourceIdentity(source) {
87268
+ if (!isHttpUrl(source)) return null;
87269
+ const identity = safeDownloadUrlIdentity(source);
87270
+ return {
87271
+ sourceFingerprint: `sha256:${identity.urlFingerprint}`,
87272
+ host: identity.host ?? "other"
87273
+ };
87274
+ }
87275
+ function downloadStatusClass(error) {
87276
+ const status = error.status ?? error.telemetry?.status;
87277
+ if (typeof status === "number" && status >= 400 && status < 500) return "http_4xx";
87278
+ if (typeof status === "number" && status >= 500 && status < 600) return "http_5xx";
87279
+ if (error.kind === "timeout") return "timeout";
87280
+ if (error.kind === "network") return "network";
87281
+ return "other";
87282
+ }
87283
+ function downloadFailureGroup(source, error) {
87284
+ const sourceIdentity = safeVideoExtractionSourceIdentity(source);
87285
+ const failureHost = error.telemetry?.finalHost ?? error.telemetry?.initialHost;
87286
+ const attempt = error.telemetry?.attempt;
87287
+ return {
87288
+ ...sourceIdentity ? { sourceFingerprint: sourceIdentity.sourceFingerprint } : {},
87289
+ ...failureHost ?? sourceIdentity?.host ? { host: failureHost ?? sourceIdentity?.host } : {},
87290
+ statusClass: downloadStatusClass(error),
87291
+ retry: {
87292
+ phase: "download",
87293
+ used: typeof attempt === "number" && attempt >= 2 ? 1 : 0,
87294
+ budget: 1
87295
+ }
87296
+ };
87297
+ }
87211
87298
  function isVideoSourceExtractionError(error) {
87212
87299
  return typeof error === "object" && error !== null && "hyperframesVideoSourceExtractionError" in error && error.hyperframesVideoSourceExtractionError === true;
87213
87300
  }
@@ -87999,6 +88086,7 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config, c
87999
88086
  videoId: video.id,
88000
88087
  kind: classified.kind,
88001
88088
  retryable: classified.retryable,
88089
+ ...err instanceof UrlDownloadError ? { group: downloadFailureGroup(video.src, err) } : {},
88002
88090
  error: classified.diagnostic
88003
88091
  });
88004
88092
  }
@@ -93096,6 +93184,7 @@ __export(src_exports, {
93096
93184
  runFfmpeg: () => runFfmpeg,
93097
93185
  runVideoExtractionWithRetry: () => runVideoExtractionWithRetry,
93098
93186
  safeDownloadUrlIdentity: () => safeDownloadUrlIdentity,
93187
+ safeVideoExtractionSourceIdentity: () => safeVideoExtractionSourceIdentity,
93099
93188
  sampleRgb48le: () => sampleRgb48le,
93100
93189
  scaleProtocolTimeoutForComposition: () => scaleProtocolTimeoutForComposition,
93101
93190
  selectVerifySampleIndicesForTask: () => selectVerifySampleIndicesForTask,
@@ -125699,12 +125788,88 @@ var init_planValidation = __esm({
125699
125788
  }
125700
125789
  });
125701
125790
 
125791
+ // ../producer/src/services/render/extractionFailureMetadata.ts
125792
+ function stringOrEmpty(value) {
125793
+ return value === void 0 ? "" : value;
125794
+ }
125795
+ function retrySortTuple(retry) {
125796
+ return retry === void 0 ? ["", -1, -1] : [retry.phase, retry.used, retry.budget];
125797
+ }
125798
+ function extractionFailureGroupSortTuple(group) {
125799
+ const retry = retrySortTuple(group.retry);
125800
+ return [
125801
+ group.kind,
125802
+ stringOrEmpty(group.sourceFingerprint),
125803
+ stringOrEmpty(group.host),
125804
+ stringOrEmpty(group.statusClass),
125805
+ retry[0],
125806
+ retry[1],
125807
+ retry[2]
125808
+ ];
125809
+ }
125810
+ function compareExtractionFailureGroups(left, right) {
125811
+ const leftTuple = extractionFailureGroupSortTuple(left);
125812
+ const rightTuple = extractionFailureGroupSortTuple(right);
125813
+ for (let index = 0; index < leftTuple.length; index += 1) {
125814
+ const leftValue = leftTuple[index];
125815
+ const rightValue = rightTuple[index];
125816
+ if (leftValue < rightValue) return -1;
125817
+ if (leftValue > rightValue) return 1;
125818
+ }
125819
+ return 0;
125820
+ }
125821
+ function extractionFailureGroupIdentityKey(group) {
125822
+ return JSON.stringify(extractionFailureGroupSortTuple(group));
125823
+ }
125824
+ var init_extractionFailureMetadata = __esm({
125825
+ "../producer/src/services/render/extractionFailureMetadata.ts"() {
125826
+ "use strict";
125827
+ }
125828
+ });
125829
+
125702
125830
  // ../producer/src/services/render/stages/extractVideosStage.ts
125703
125831
  import { existsSync as existsSync51 } from "fs";
125704
125832
  import { join as join50 } from "path";
125705
125833
  function shouldCopyExtractedFrames(platform11) {
125706
125834
  return platform11 === "win32";
125707
125835
  }
125836
+ function buildExtractionFailureMetadata(inputs) {
125837
+ const kindCounts = /* @__PURE__ */ new Map();
125838
+ const grouped = /* @__PURE__ */ new Map();
125839
+ for (const input2 of inputs) {
125840
+ kindCounts.set(input2.kind, (kindCounts.get(input2.kind) ?? 0) + input2.affectedElementCount);
125841
+ const key2 = extractionFailureGroupIdentityKey(input2);
125842
+ const existing = grouped.get(key2);
125843
+ if (existing) {
125844
+ existing.affectedElementCount += input2.affectedElementCount;
125845
+ } else {
125846
+ grouped.set(key2, { ...input2 });
125847
+ }
125848
+ }
125849
+ const allGroups = [...grouped.values()].sort(compareExtractionFailureGroups);
125850
+ return {
125851
+ schemaVersion: 1,
125852
+ kindCounts: [...kindCounts].map(([kind, affectedElementCount]) => ({
125853
+ kind,
125854
+ affectedElementCount
125855
+ })).sort((a, b2) => a.kind < b2.kind ? -1 : a.kind > b2.kind ? 1 : 0),
125856
+ groups: allGroups.slice(0, MAX_EXTRACTION_FAILURE_GROUPS),
125857
+ omittedGroupCount: Math.max(0, allGroups.length - MAX_EXTRACTION_FAILURE_GROUPS)
125858
+ };
125859
+ }
125860
+ function extractionFailureMetadataFromResult(result) {
125861
+ return buildExtractionFailureMetadata(
125862
+ result.errors.map((failure) => ({
125863
+ kind: failure.kind ?? "internal",
125864
+ affectedElementCount: 1,
125865
+ ...failure.group
125866
+ }))
125867
+ );
125868
+ }
125869
+ function safeVideoExtractionSourceLogMetadata(source) {
125870
+ const identity = safeVideoExtractionSourceIdentity(source);
125871
+ return identity ? { sourceType: "remote", ...identity } : { sourceType: "local" };
125872
+ }
125708
125873
  function resolveVideoExtractionPolicy(env = process.env) {
125709
125874
  const rawMode = env.HF_VIDEO_EXTRACTION_FAILURE_MODE?.trim().toLowerCase();
125710
125875
  const failureMode = rawMode === "observe" || rawMode === "off" ? rawMode : "enforce";
@@ -125738,7 +125903,8 @@ function buildVideoExtractionStageError(result) {
125738
125903
  return new VideoExtractionStageError(
125739
125904
  retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
125740
125905
  retryable,
125741
- failures
125906
+ failures,
125907
+ extractionFailureMetadataFromResult(result)
125742
125908
  );
125743
125909
  }
125744
125910
  function buildHdrProbeStageError(failures) {
@@ -125753,7 +125919,13 @@ function buildHdrProbeStageError(failures) {
125753
125919
  return new VideoExtractionStageError(
125754
125920
  retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
125755
125921
  retryable,
125756
- summary
125922
+ summary,
125923
+ buildExtractionFailureMetadata(
125924
+ failures.map((failure) => ({
125925
+ kind: failure.kind,
125926
+ affectedElementCount: 1
125927
+ }))
125928
+ )
125757
125929
  );
125758
125930
  }
125759
125931
  function isHdrProbeFailure(failure) {
@@ -125880,7 +126052,10 @@ async function runExtractVideosStage(input2) {
125880
126052
  const totalVideos = composition.videos.length;
125881
126053
  for (let i2 = 0; i2 < totalVideos; i2++) {
125882
126054
  const v2 = composition.videos[i2];
125883
- log2?.info(`Extracting frames from video ${i2 + 1}/${totalVideos}: ${v2.src}`);
126055
+ log2?.info(
126056
+ `Extracting frames from video ${i2 + 1}/${totalVideos}`,
126057
+ safeVideoExtractionSourceLogMetadata(v2.src)
126058
+ );
125884
126059
  }
125885
126060
  extractionResult = await extractAllVideoFrames(
125886
126061
  composition.videos,
@@ -125952,7 +126127,7 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
125952
126127
  existingAudioSrcs.add(video.src);
125953
126128
  }
125954
126129
  }
125955
- var VideoExtractionStageError;
126130
+ var MAX_EXTRACTION_FAILURE_GROUPS, VideoExtractionStageError;
125956
126131
  var init_extractVideosStage = __esm({
125957
126132
  "../producer/src/services/render/stages/extractVideosStage.ts"() {
125958
126133
  "use strict";
@@ -125960,19 +126135,35 @@ var init_extractVideosStage = __esm({
125960
126135
  init_renderOrchestrator();
125961
126136
  init_shared();
125962
126137
  init_encoderInterruption();
126138
+ init_extractionFailureMetadata();
126139
+ MAX_EXTRACTION_FAILURE_GROUPS = 8;
125963
126140
  VideoExtractionStageError = class extends Error {
125964
- constructor(code, retryable, failures) {
126141
+ constructor(code, retryable, failures, extractionFailure = buildExtractionFailureMetadata(
126142
+ failures.map((failure) => ({
126143
+ kind: failure.kind,
126144
+ affectedElementCount: failure.count
126145
+ }))
126146
+ )) {
125965
126147
  const total = failures.reduce((sum, failure) => sum + failure.count, 0);
125966
126148
  const breakdown = failures.map((failure) => `${failure.kind}=${failure.count}`).join(",");
125967
126149
  super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]`);
125968
126150
  this.code = code;
125969
126151
  this.retryable = retryable;
125970
126152
  this.failures = failures;
126153
+ this.extractionFailure = extractionFailure;
125971
126154
  this.name = "VideoExtractionStageError";
126155
+ this.publicMetadata = { extractionFailure };
125972
126156
  }
125973
126157
  code;
125974
126158
  retryable;
125975
126159
  failures;
126160
+ extractionFailure;
126161
+ /**
126162
+ * Producer servers may expose this explicitly public, JSON-compatible data
126163
+ * without knowing its schema. Boundary adapters remain responsible for
126164
+ * validating and applying policy to it.
126165
+ */
126166
+ publicMetadata;
125976
126167
  };
125977
126168
  }
125978
126169
  });
@@ -132280,10 +132471,12 @@ function extractSafeRenderErrorMetadata(error) {
132280
132471
  if (!errorCode || typeof error !== "object" || error === null) return void 0;
132281
132472
  const owner = "owner" in error ? error.owner : void 0;
132282
132473
  const retryable = "retryable" in error ? error.retryable : void 0;
132474
+ const publicMetadata = "publicMetadata" in error ? error.publicMetadata : void 0;
132283
132475
  return {
132284
132476
  errorCode,
132285
132477
  errorOwner: owner === "user" || owner === "system" ? owner : void 0,
132286
- retryable: typeof retryable === "boolean" ? retryable : void 0
132478
+ retryable: typeof retryable === "boolean" ? retryable : void 0,
132479
+ ...isPlainObject4(publicMetadata) ? { errorMetadata: publicMetadata } : {}
132287
132480
  };
132288
132481
  }
132289
132482
  function parseServerFps(value) {
@@ -132605,6 +132798,7 @@ async function writeRenderStreamFailure(input2) {
132605
132798
  errorCode: safeError?.errorCode,
132606
132799
  errorOwner: safeError?.errorOwner,
132607
132800
  retryable: safeError?.retryable,
132801
+ errorMetadata: safeError?.errorMetadata,
132608
132802
  stage: job.currentStage,
132609
132803
  elapsedMs,
132610
132804
  errorDetails: job.errorDetails ?? null,
@@ -132730,6 +132924,7 @@ function createRenderHandlers(options = {}) {
132730
132924
  errorCode: safeError?.errorCode,
132731
132925
  errorOwner: safeError?.errorOwner,
132732
132926
  retryable: safeError?.retryable,
132927
+ errorMetadata: safeError?.errorMetadata,
132733
132928
  stage: job.currentStage,
132734
132929
  durationMs,
132735
132930
  errorDetails: job.errorDetails ?? null
@@ -149583,18 +149778,22 @@ function geometryIssueAnchor(candidate, time) {
149583
149778
  rect: candidate.rect
149584
149779
  };
149585
149780
  }
149586
- function captionCenterInZone(rect, zone, canvas) {
149587
- const cx = rect.left + rect.width / 2;
149588
- const cy = rect.top + rect.height / 2;
149589
- const inside = cx >= zone.x0 * canvas.width && cx <= zone.x1 * canvas.width && cy >= zone.y0 * canvas.height && cy <= zone.y1 * canvas.height;
149590
- return { inside, cy };
149781
+ function captionBoxOverlapsZone(box, zone, canvas) {
149782
+ const zx0 = zone.x0 * canvas.width;
149783
+ const zy0 = zone.y0 * canvas.height;
149784
+ const zx1 = zone.x1 * canvas.width;
149785
+ const zy1 = zone.y1 * canvas.height;
149786
+ const bx1 = box.left + box.width;
149787
+ const by1 = box.top + box.height;
149788
+ const overlaps = box.left < zx1 && bx1 > zx0 && box.top < zy1 && by1 > zy0;
149789
+ return { overlaps, cy: box.top + box.height / 2 };
149591
149790
  }
149592
149791
  function captionFinding(candidate, options, canvas, time) {
149593
149792
  const zone = options.captionZone;
149594
149793
  if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null;
149595
149794
  if ("data-layout-allow-caption-zone" in candidate.dataAttributes) return null;
149596
- const { inside, cy } = captionCenterInZone(candidate.rect, zone, canvas);
149597
- if (!inside) return null;
149795
+ const { overlaps, cy } = captionBoxOverlapsZone(candidate.elementRect, zone, canvas);
149796
+ if (!overlaps) return null;
149598
149797
  const text2 = candidate.text.slice(0, 48);
149599
149798
  const pctFromBottom = Math.round((canvas.height - cy) / canvas.height * 100);
149600
149799
  return {
@@ -149604,7 +149803,7 @@ function captionFinding(candidate, options, canvas, time) {
149604
149803
  code: "caption_zone_collision",
149605
149804
  severity: zone.severity === "error" ? "error" : "warning",
149606
149805
  text: text2,
149607
- message: `<${candidate.tag}> "${text2}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`,
149806
+ message: `<${candidate.tag}> "${text2}" overlaps the reserved caption band (~${pctFromBottom}% up from the bottom).`,
149608
149807
  fixHint: "Keep main content outside the configured caption band, or mark intentional lower-third copy with data-layout-allow-caption-zone."
149609
149808
  }
149610
149809
  };
@@ -203174,18 +203373,25 @@ async function captionImagesWithGemini(outputDir, progress, warnings, options =
203174
203373
  }
203175
203374
  const openRouterKey = process.env.OPENROUTER_API_KEY;
203176
203375
  const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
203177
- if (!openRouterKey && !geminiKey) {
203376
+ const vertexProject = process.env.HYPERFRAMES_VERTEX_PROJECT_ID;
203377
+ const vertexServiceAccount = process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT;
203378
+ const useVertex = Boolean(vertexProject && vertexServiceAccount);
203379
+ if (!openRouterKey && !useVertex && !geminiKey) {
203178
203380
  reportOutcome();
203179
203381
  return geminiCaptions;
203180
203382
  }
203181
- const useOpenRouter = Boolean(openRouterKey);
203182
- const providerName = useOpenRouter ? "OpenRouter" : "Gemini";
203183
- const model = useOpenRouter ? process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite" : process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
203383
+ const provider = openRouterKey ? "openrouter" : useVertex ? "vertex" : "gemini";
203384
+ const providerName = { openrouter: "OpenRouter", vertex: "Vertex AI", gemini: "Gemini" }[provider];
203385
+ const model = {
203386
+ openrouter: process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite",
203387
+ vertex: process.env.HYPERFRAMES_VERTEX_MODEL || "gemini-2.5-flash",
203388
+ gemini: process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview"
203389
+ }[provider];
203184
203390
  const requestTimeoutMs = resolveVisionRequestTimeoutMs();
203185
203391
  progress("design", `Captioning images with ${providerName} vision...`);
203186
203392
  try {
203187
203393
  let captionOne;
203188
- if (openRouterKey) {
203394
+ if (provider === "openrouter") {
203189
203395
  captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => {
203190
203396
  return runBoundedVisionRequest(async (signal) => {
203191
203397
  const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
@@ -203221,9 +203427,31 @@ async function captionImagesWithGemini(outputDir, progress, warnings, options =
203221
203427
  }, timeoutMs);
203222
203428
  };
203223
203429
  } else {
203224
- if (!geminiKey) return geminiCaptions;
203225
203430
  const { GoogleGenAI: GoogleGenAI2 } = await Promise.resolve().then(() => (init_node4(), node_exports));
203226
- const ai = new GoogleGenAI2({ apiKey: geminiKey });
203431
+ let ai;
203432
+ if (provider === "vertex") {
203433
+ if (!vertexProject || !vertexServiceAccount) return geminiCaptions;
203434
+ let credentials;
203435
+ try {
203436
+ credentials = JSON.parse(vertexServiceAccount);
203437
+ } catch {
203438
+ warnings.push(
203439
+ "HYPERFRAMES_VERTEX_SERVICE_ACCOUNT is not valid JSON; skipped vision captioning."
203440
+ );
203441
+ internalError = true;
203442
+ reportOutcome();
203443
+ return geminiCaptions;
203444
+ }
203445
+ ai = new GoogleGenAI2({
203446
+ vertexai: true,
203447
+ project: vertexProject,
203448
+ location: process.env.HYPERFRAMES_VERTEX_LOCATION || "us-central1",
203449
+ googleAuthOptions: { credentials }
203450
+ });
203451
+ } else {
203452
+ if (!geminiKey) return geminiCaptions;
203453
+ ai = new GoogleGenAI2({ apiKey: geminiKey });
203454
+ }
203227
203455
  captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => {
203228
203456
  const response = await runBoundedVisionRequest(
203229
203457
  (signal) => ai.models.generateContent({
@@ -203236,6 +203464,12 @@ async function captionImagesWithGemini(outputDir, progress, warnings, options =
203236
203464
  ],
203237
203465
  config: {
203238
203466
  maxOutputTokens: maxTokens,
203467
+ // A one-line factual caption needs no reasoning, and leaving thinking on is
203468
+ // not merely wasteful: thinking tokens are drawn from maxOutputTokens, so the
203469
+ // model can spend the whole budget and return empty text. That surfaces as a
203470
+ // successful request with no caption — the capture then logs "Captioned N/N
203471
+ // images" followed by "0 images captioned", which is what production shows.
203472
+ thinkingConfig: { thinkingBudget: 0 },
203239
203473
  abortSignal: signal,
203240
203474
  httpOptions: { timeout: timeoutMs }
203241
203475
  }
@@ -203327,6 +203561,7 @@ async function captionImagesWithGemini(outputDir, progress, warnings, options =
203327
203561
  reportOutcome();
203328
203562
  return geminiCaptions;
203329
203563
  }
203564
+ const hostConcurrency = sharp3.concurrency();
203330
203565
  progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`);
203331
203566
  const SVG_BATCH = 20;
203332
203567
  const SVG_RENDER_SIZE = 256;
@@ -203339,10 +203574,11 @@ async function captionImagesWithGemini(outputDir, progress, warnings, options =
203339
203574
  }
203340
203575
  const timeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs));
203341
203576
  const batch = svgFiles.slice(i2, i2 + SVG_BATCH);
203342
- const results = await Promise.allSettled(
203343
- batch.map(async ({ relPath }) => {
203577
+ const rasterized = [];
203578
+ sharp3.concurrency(1);
203579
+ try {
203580
+ for (const { relPath } of batch) {
203344
203581
  const filePath = join121(assetsDir, relPath);
203345
- let pngBase64;
203346
203582
  try {
203347
203583
  const svgSource = readFileSync84(filePath, "utf-8");
203348
203584
  const lightFillHits = (svgSource.match(/fill\s*=\s*["'](#fff(fff)?|white|#[ef][ef][ef]|#[ef]{6})["']/gi) || []).length;
@@ -203354,11 +203590,18 @@ async function captionImagesWithGemini(outputDir, progress, warnings, options =
203354
203590
  fit: "inside",
203355
203591
  withoutEnlargement: false
203356
203592
  }).flatten({ background: bg }).png().toBuffer();
203357
- pngBase64 = pngBuffer.toString("base64");
203593
+ rasterized.push({ relPath, pngBase64: pngBuffer.toString("base64") });
203358
203594
  } catch {
203359
203595
  svgsSkipped++;
203360
- return { file: relPath, caption: "" };
203596
+ rasterized.push({ relPath, pngBase64: null });
203361
203597
  }
203598
+ }
203599
+ } finally {
203600
+ sharp3.concurrency(hostConcurrency);
203601
+ }
203602
+ const results = await Promise.allSettled(
203603
+ rasterized.map(async ({ relPath, pngBase64 }) => {
203604
+ if (pngBase64 === null) return { file: relPath, caption: "" };
203362
203605
  const caption = await captionOne({
203363
203606
  mimeType: "image/png",
203364
203607
  base64: pngBase64,
@@ -204810,8 +205053,8 @@ ${extracted.bodyHtml}
204810
205053
  try {
204811
205054
  const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
204812
205055
  if (lines.length > 0) {
204813
- const hasVisionKey = !!(!skipVision && (process.env.OPENROUTER_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY));
204814
- const header = hasVisionKey ? "# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file \u2014 that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim \u2014 many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n" : "# Asset Descriptions\n\n\u26A0\uFE0F GEMINI_API_KEY not set \u2014 descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY) and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing \u2014 composing a fake logo ships off-brand in the final video.\n\n";
205056
+ const hasVisionKey = !!(!skipVision && (process.env.OPENROUTER_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.HYPERFRAMES_VERTEX_PROJECT_ID && process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT));
205057
+ const header = hasVisionKey ? "# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file \u2014 that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim \u2014 many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n" : "# Asset Descriptions\n\n\u26A0\uFE0F No vision credentials \u2014 descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY), or HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing \u2014 composing a fake logo ships off-brand in the final video.\n\n";
204815
205058
  writeFileSync56(
204816
205059
  join126(outputDir, "extracted", "asset-descriptions.md"),
204817
205060
  header + lines.map((l) => "- " + l).join("\n") + "\n",