ima2-gen 2.0.0 → 2.0.1

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.
Files changed (64) hide show
  1. package/README.md +2 -11
  2. package/bin/commands/backfillThumbs.js +18 -0
  3. package/bin/commands/edit.js +7 -6
  4. package/bin/commands/gen.js +7 -6
  5. package/bin/commands/multimode.js +5 -4
  6. package/bin/commands/node.js +4 -4
  7. package/bin/ima2.js +7 -1
  8. package/bin/lib/config-store.js +1 -1
  9. package/docs/API.md +55 -4
  10. package/docs/CLI.md +9 -3
  11. package/docs/PROMPT_STUDIO.md +3 -1
  12. package/docs/migration/runtime-test-inventory.md +3 -1
  13. package/lib/agentRuntime.js +22 -16
  14. package/lib/agentSettings.js +1 -1
  15. package/lib/agyImageAdapter.js +232 -0
  16. package/lib/capabilities.js +2 -1
  17. package/lib/configKeys.js +1 -1
  18. package/lib/geminiApiImageAdapter.js +183 -0
  19. package/lib/grokImageAdapter.js +16 -9
  20. package/lib/grokMultimodeAdapter.js +2 -1
  21. package/lib/grokRuntime.js +3 -0
  22. package/lib/grokSizeMapper.js +13 -1
  23. package/lib/grokVideoAdapter.js +14 -7
  24. package/lib/historyList.js +18 -2
  25. package/lib/imageModels.js +15 -0
  26. package/lib/imageThumb.js +38 -0
  27. package/lib/providerOptions.js +36 -1
  28. package/lib/responsesFallback.js +52 -44
  29. package/lib/runtimeContext.js +27 -0
  30. package/lib/storageMigration.js +1 -1
  31. package/lib/thumbBackfill.js +59 -0
  32. package/lib/vertexAuth.js +44 -0
  33. package/lib/videoThumb.js +60 -0
  34. package/package.json +4 -2
  35. package/routes/auth.js +238 -0
  36. package/routes/edit.js +41 -7
  37. package/routes/generate.js +40 -12
  38. package/routes/history.js +13 -0
  39. package/routes/index.js +4 -0
  40. package/routes/keys.js +254 -0
  41. package/routes/multimode.js +39 -6
  42. package/routes/nodes.js +57 -35
  43. package/routes/quota.js +58 -7
  44. package/routes/video.js +7 -3
  45. package/server.js +123 -0
  46. package/ui/dist/.vite/manifest.json +12 -12
  47. package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +3 -0
  48. package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dqyc1WZ1.js} +2 -2
  49. package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-ChEXzQbb.js} +2 -2
  50. package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-B95ZufnR.js} +1 -1
  51. package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-DGOwFQET.js} +2 -2
  52. package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CgvdnR49.js} +1 -1
  53. package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-CfUye9J8.js} +1 -1
  54. package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-B9kndPw1.js} +2 -2
  55. package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +1 -0
  56. package/ui/dist/assets/index-BhcvL0g-.js +1 -0
  57. package/ui/dist/assets/index-BtK3YhJc.js +39 -0
  58. package/ui/dist/assets/index-ClOLOjnA.css +1 -0
  59. package/ui/dist/index.html +2 -2
  60. package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
  61. package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
  62. package/ui/dist/assets/index-BAFI6htx.js +0 -42
  63. package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
  64. package/ui/dist/assets/index-DS-ADE7U.css +0 -1
@@ -2,6 +2,8 @@ import { mkdir, readFile, readdir, stat } from "fs/promises";
2
2
  import { dirname, join } from "path";
3
3
  import { config } from "../config.js";
4
4
  import { readEmbeddedImageMetadataFromFile } from "./imageMetadataStore.js";
5
+ import { thumbPathForVideo, thumbUrlForVideo } from "./videoThumb.js";
6
+ import { thumbPathForImage, thumbUrlForImage } from "./imageThumb.js";
5
7
  import { errInfo } from "./errInfo.js";
6
8
  async function listImageFiles(baseDir) {
7
9
  const out = [];
@@ -14,7 +16,7 @@ async function listImageFiles(baseDir) {
14
16
  if (entry.isDirectory() && depth > 0) {
15
17
  await walk(full, depth - 1);
16
18
  }
17
- else if (entry.isFile() && /\.(png|jpe?g|webp|mp4)$/i.test(entry.name)) {
19
+ else if (entry.isFile() && /\.(png|jpe?g|webp|mp4)$/i.test(entry.name) && !entry.name.endsWith(".thumb.jpg")) {
18
20
  out.push({ full, rel: full.slice(baseDir.length + 1), name: entry.name });
19
21
  }
20
22
  }
@@ -29,10 +31,24 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
29
31
  const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
30
32
  const st = await stat(full).catch(() => null);
31
33
  const meta = await readImageMetadata(full, rel);
34
+ const isVideo = /\.mp4$/i.test(name);
35
+ const encodedUrl = `/generated/${rel.split("/").map(encodeURIComponent).join("/")}`;
36
+ let thumb = null;
37
+ if (isVideo) {
38
+ const thumbExists = await stat(thumbPathForVideo(full)).then(() => true, () => false);
39
+ if (thumbExists)
40
+ thumb = thumbUrlForVideo(encodedUrl);
41
+ }
42
+ else {
43
+ const imgThumbExists = await stat(thumbPathForImage(full)).then(() => true, () => false);
44
+ if (imgThumbExists)
45
+ thumb = thumbUrlForImage(encodedUrl);
46
+ }
32
47
  return {
33
48
  filename: rel,
34
49
  url: `/generated/${rel.split("/").map(encodeURIComponent).join("/")}`,
35
- mediaType: meta?.mediaType || (/\.mp4$/i.test(name) ? "video" : "image"),
50
+ thumb,
51
+ mediaType: meta?.mediaType || (isVideo ? "video" : "image"),
36
52
  video: meta?.video || null,
37
53
  videoSeries: meta?.videoSeries || null,
38
54
  videoContinuity: meta?.videoContinuity || null,
@@ -5,6 +5,8 @@ const FALLBACK_REASONING_EFFORT = "none";
5
5
  const VALID_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]);
6
6
  const GROK_FALLBACK_IMAGE_MODEL = "grok-imagine-image";
7
7
  const VALID_GROK_IMAGE_MODELS = new Set(["grok-imagine-image", "grok-imagine-image-quality"]);
8
+ const GEMINI_API_FALLBACK_IMAGE_MODEL = "nano-banana-2";
9
+ const VALID_GEMINI_API_MODELS = new Set(["nano-banana-2", "nano-banana-pro"]);
8
10
  export function normalizeReasoningEffort(ctx, rawEffort) {
9
11
  const configured = ctx?.config?.imageModels;
10
12
  const fallback = configured?.reasoningEffort ?? FALLBACK_REASONING_EFFORT;
@@ -58,6 +60,19 @@ export function normalizeGrokImageModel(rawModel) {
58
60
  }
59
61
  return { model: rawModel };
60
62
  }
63
+ export function normalizeGeminiApiModel(rawModel) {
64
+ if (typeof rawModel !== "string" || rawModel.length === 0) {
65
+ return { model: GEMINI_API_FALLBACK_IMAGE_MODEL };
66
+ }
67
+ if (!VALID_GEMINI_API_MODELS.has(rawModel)) {
68
+ return {
69
+ error: `Gemini API image model must be one of: ${[...VALID_GEMINI_API_MODELS].join(", ")}`,
70
+ code: "INVALID_GEMINI_API_IMAGE_MODEL",
71
+ status: 400,
72
+ };
73
+ }
74
+ return { model: rawModel };
75
+ }
61
76
  // ── Grok video (T2V/I2V) ─────────────────────────────────────────────────
62
77
  // Video is a separate generation kind, not an image model. Keep it out of the
63
78
  // image model unions/helpers above so `grok-` image classification is unaffected.
@@ -0,0 +1,38 @@
1
+ import sharp from "sharp";
2
+ import { stat, writeFile } from "node:fs/promises";
3
+ const THUMB_WIDTH = 320;
4
+ const THUMB_QUALITY = 70;
5
+ // Guard against decompression-bomb memory exhaustion (default sharp limit is 268M px).
6
+ const MAX_INPUT_PIXELS = 100_000_000; // 100MP (e.g. ~10000x10000)
7
+ export function thumbPathForImage(imagePath) {
8
+ return imagePath.replace(/\.(png|jpe?g|webp)$/i, ".thumb.jpg");
9
+ }
10
+ export function thumbUrlForImage(imageUrl) {
11
+ return imageUrl.replace(/\.(png|jpe?g|webp)$/i, ".thumb.jpg");
12
+ }
13
+ export async function generateImageThumbnail(imagePath) {
14
+ const thumbPath = thumbPathForImage(imagePath);
15
+ const buf = await sharp(imagePath, { limitInputPixels: MAX_INPUT_PIXELS })
16
+ .resize({ width: THUMB_WIDTH, withoutEnlargement: true })
17
+ .jpeg({ quality: THUMB_QUALITY })
18
+ .toBuffer();
19
+ await writeFile(thumbPath, buf);
20
+ return thumbPath;
21
+ }
22
+ export async function generateImageThumbnailFromBuffer(buffer, outputPath) {
23
+ const thumbPath = thumbPathForImage(outputPath);
24
+ const buf = await sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS })
25
+ .resize({ width: THUMB_WIDTH, withoutEnlargement: true })
26
+ .jpeg({ quality: THUMB_QUALITY })
27
+ .toBuffer();
28
+ await writeFile(thumbPath, buf);
29
+ }
30
+ export async function imageThumbExists(imageFullPath) {
31
+ try {
32
+ await stat(thumbPathForImage(imageFullPath));
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
@@ -1,5 +1,26 @@
1
- import { normalizeImageModel, normalizeReasoningEffort, normalizeGrokImageModel } from "./imageModels.js";
1
+ import { normalizeImageModel, normalizeReasoningEffort, normalizeGrokImageModel, normalizeGeminiApiModel } from "./imageModels.js";
2
2
  export function resolveProviderOptions(ctx, { provider = "oauth", rawModel, rawReasoningEffort, rawSize = "1024x1024", rawWebSearchEnabled = true, searchMode = "on", } = {}) {
3
+ if (provider === "agy") {
4
+ return {
5
+ provider: "agy",
6
+ model: "nano-banana-2",
7
+ reasoningEffort: "none",
8
+ size: "1024x1024",
9
+ webSearchEnabled: false,
10
+ };
11
+ }
12
+ if (provider === "gemini-api") {
13
+ const geminiModelCheck = normalizeGeminiApiModel(rawModel || "nano-banana-2");
14
+ if (geminiModelCheck.error)
15
+ return { error: geminiModelCheck.error, code: geminiModelCheck.code, status: geminiModelCheck.status };
16
+ return {
17
+ provider: "gemini-api",
18
+ model: geminiModelCheck.model,
19
+ reasoningEffort: "none",
20
+ size: rawSize || "1024x1024",
21
+ webSearchEnabled: false,
22
+ };
23
+ }
3
24
  if (provider === "grok") {
4
25
  const grokCfg = ctx?.config?.grokProvider || {};
5
26
  const modelInput = rawModel || grokCfg.defaultImageModel;
@@ -14,6 +35,20 @@ export function resolveProviderOptions(ctx, { provider = "oauth", rawModel, rawR
14
35
  webSearchEnabled: true,
15
36
  };
16
37
  }
38
+ if (provider === "grok-api") {
39
+ const grokCfg = ctx?.config?.grokProvider || {};
40
+ const modelInput = rawModel || grokCfg.defaultImageModel;
41
+ const grokModelCheck = normalizeGrokImageModel(modelInput);
42
+ if (grokModelCheck.error)
43
+ return { error: grokModelCheck.error, code: grokModelCheck.code, status: grokModelCheck.status };
44
+ return {
45
+ provider: "grok-api",
46
+ model: grokModelCheck.model,
47
+ reasoningEffort: "none",
48
+ size: rawSize,
49
+ webSearchEnabled: true,
50
+ };
51
+ }
17
52
  const activeProvider = provider === "api" ? "api" : "oauth";
18
53
  const apiConfig = ctx?.config?.apiProvider || {};
19
54
  const modelInput = activeProvider === "api"
@@ -1,57 +1,65 @@
1
1
  import { logEvent } from "./logger.js";
2
2
  import { imageToolChoice, tools } from "./responsesTools.js";
3
3
  import { emptyResponseError } from "./responsesErrors.js";
4
- import { buildUserTextPrompt } from "./oauthProxy.js";
4
+ import { GENERATE_DEVELOPER_PROMPT, GENERATE_NO_SEARCH_DEVELOPER_PROMPT, buildUserTextPrompt, } from "./oauthProxy.js";
5
+ const MAX_RETRIES = 2;
5
6
  export async function retryPromptOnlyJsonImage({ postResponses, ctx, provider, prompt, mode, model, quality, size, moderation, requestId, signal, initial, referencesDroppedOnRetry, webSearchDroppedOnRetry, reasoningEffort, }) {
6
7
  if (provider === "api")
7
8
  return null;
8
- const retryKind = "prompt_only_json_image_tool";
9
+ const retryKind = "prompt_only_with_developer";
9
10
  const retryMeta = {
10
11
  retryKind,
11
12
  initialEventCount: initial.eventCount,
12
13
  initialEventTypes: initial.eventTypes,
13
14
  referencesDroppedOnRetry,
14
- developerPromptDroppedOnRetry: true,
15
+ developerPromptDroppedOnRetry: false,
15
16
  webSearchDroppedOnRetry,
16
17
  };
17
- logEvent("oauth", "retry_json", { requestId, ...retryMeta });
18
- let retry;
19
- try {
20
- retry = await postResponses({
21
- ctx,
22
- provider,
23
- scope: "oauth-fallback",
24
- requestId,
25
- maxImages: 1,
26
- signal,
27
- payload: {
28
- model,
29
- input: [{ role: "user", content: buildUserTextPrompt(prompt, mode, { webSearchEnabled: false }) }],
30
- tools: tools(false, { quality, size, moderation }),
31
- tool_choice: imageToolChoice(true),
32
- reasoning: { effort: reasoningEffort || "low" },
33
- stream: false,
34
- },
35
- });
18
+ const developerPrompt = webSearchDroppedOnRetry
19
+ ? GENERATE_NO_SEARCH_DEVELOPER_PROMPT
20
+ : GENERATE_DEVELOPER_PROMPT;
21
+ let lastRetry = null;
22
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
23
+ logEvent("oauth", "retry_attempt", { requestId, attempt, maxRetries: MAX_RETRIES, ...retryMeta });
24
+ try {
25
+ lastRetry = await postResponses({
26
+ ctx,
27
+ provider,
28
+ scope: "oauth-fallback",
29
+ requestId,
30
+ maxImages: 1,
31
+ signal,
32
+ payload: {
33
+ model,
34
+ input: [
35
+ { role: "developer", content: developerPrompt },
36
+ { role: "user", content: buildUserTextPrompt(prompt, mode, { webSearchEnabled: false }) },
37
+ ],
38
+ tools: tools(false, { quality, size, moderation }),
39
+ tool_choice: imageToolChoice(true),
40
+ reasoning: { effort: reasoningEffort || "low" },
41
+ stream: false,
42
+ },
43
+ });
44
+ }
45
+ catch (e) {
46
+ if (attempt === MAX_RETRIES) {
47
+ if (e && typeof e === "object")
48
+ Object.assign(e, retryMeta);
49
+ throw e;
50
+ }
51
+ logEvent("oauth", "retry_error", { requestId, attempt, error: e.message, status: e.status, code: e.code });
52
+ continue;
53
+ }
54
+ const image = lastRetry.images[0];
55
+ if (image?.b64) {
56
+ logEvent("oauth", "retry_image", { requestId, retryKind, attempt, imageChars: image.b64.length });
57
+ return { b64: image.b64, usage: lastRetry.usage, webSearchCalls: initial.webSearchCalls, revisedPrompt: image.revisedPrompt, text: lastRetry.text, ...retryMeta };
58
+ }
59
+ logEvent("oauth", "retry_no_image", { requestId, retryKind, attempt, fallbackEventCount: lastRetry.eventCount });
36
60
  }
37
- catch (e) {
38
- if (e && typeof e === "object")
39
- Object.assign(e, retryMeta);
40
- throw e;
41
- }
42
- const image = retry.images[0];
43
- if (image?.b64) {
44
- logEvent("oauth", "retry_image", { requestId, retryKind, imageChars: image.b64.length });
45
- return { b64: image.b64, usage: retry.usage, webSearchCalls: initial.webSearchCalls, revisedPrompt: image.revisedPrompt, text: retry.text, ...retryMeta };
46
- }
47
- logEvent("oauth", "retry_no_image", {
48
- requestId,
49
- retryKind,
50
- fallbackEventCount: retry.eventCount,
51
- fallbackImageCallSeen: retry.diagnostics.imageCallSeen,
52
- fallbackImageResultCount: retry.diagnostics.imageResultCount,
53
- });
54
- throw emptyResponseError("No image data received from Responses API fallback", retry, {
61
+ const diagSource = lastRetry ?? initial;
62
+ throw emptyResponseError("No image data received after retries", diagSource, {
55
63
  provider,
56
64
  model,
57
65
  quality,
@@ -64,9 +72,9 @@ export async function retryPromptOnlyJsonImage({ postResponses, ctx, provider, p
64
72
  toolTypes: ["image_generation"],
65
73
  toolChoiceKind: "image_generation",
66
74
  ...retryMeta,
67
- fallbackEventCount: retry.eventCount,
68
- fallbackEventTypes: retry.eventTypes,
69
- fallbackImageCallSeen: retry.diagnostics.imageCallSeen,
70
- fallbackImageResultCount: retry.diagnostics.imageResultCount,
75
+ fallbackEventCount: diagSource.eventCount,
76
+ fallbackEventTypes: diagSource.eventTypes,
77
+ fallbackImageCallSeen: diagSource.diagnostics.imageCallSeen,
78
+ fallbackImageResultCount: diagSource.diagnostics.imageResultCount,
71
79
  });
72
80
  }
@@ -57,6 +57,24 @@ export function requireRuntimeContext(ctx) {
57
57
  }
58
58
  if (target.startedAt === undefined)
59
59
  target.startedAt = Date.now();
60
+ if (target.xaiApiKey === undefined && !Object.prototype.hasOwnProperty.call(target, 'xaiApiKey'))
61
+ target.xaiApiKey = undefined;
62
+ if (target.hasXaiApiKey === undefined)
63
+ target.hasXaiApiKey = false;
64
+ if (target.xaiApiKeySource === undefined)
65
+ target.xaiApiKeySource = undefined;
66
+ if (target.geminiApiKey === undefined && !Object.prototype.hasOwnProperty.call(target, 'geminiApiKey'))
67
+ target.geminiApiKey = undefined;
68
+ if (target.hasGeminiApiKey === undefined)
69
+ target.hasGeminiApiKey = false;
70
+ if (target.geminiApiKeySource === undefined)
71
+ target.geminiApiKeySource = undefined;
72
+ if (target.vertexServiceAccountJson === undefined && !Object.prototype.hasOwnProperty.call(target, 'vertexServiceAccountJson'))
73
+ target.vertexServiceAccountJson = undefined;
74
+ if (target.vertexProjectId === undefined)
75
+ target.vertexProjectId = undefined;
76
+ if (target.hasVertexKey === undefined)
77
+ target.hasVertexKey = false;
60
78
  return target;
61
79
  }
62
80
  /** Per-top-level-key merge: caller's nested config keys win, missing nests
@@ -107,6 +125,15 @@ export function createTestRuntimeContext(over = {}) {
107
125
  serverConfiguredPort: 11783,
108
126
  serverUrl: "http://127.0.0.1:11783",
109
127
  startedAt: now,
128
+ xaiApiKey: undefined,
129
+ xaiApiKeySource: undefined,
130
+ hasXaiApiKey: false,
131
+ geminiApiKey: undefined,
132
+ geminiApiKeySource: undefined,
133
+ hasGeminiApiKey: false,
134
+ vertexServiceAccountJson: undefined,
135
+ vertexProjectId: undefined,
136
+ hasVertexKey: false,
110
137
  };
111
138
  return { ...base, ...over };
112
139
  }
@@ -286,7 +286,7 @@ function getGlobalPrefixCandidates({ env, execPath, argv1 }) {
286
286
  return Array.from(prefixes);
287
287
  }
288
288
  function addHomebrewPrefix(prefixes, execPath) {
289
- const marker = `${sep}Cellar${sep}node`;
289
+ const marker = "/Cellar/node";
290
290
  const idx = execPath.indexOf(marker);
291
291
  if (idx > 0)
292
292
  prefixes.add(execPath.slice(0, idx));
@@ -0,0 +1,59 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ensureVideoThumbnail, videoThumbExists } from "./videoThumb.js";
4
+ import { generateImageThumbnail, imageThumbExists } from "./imageThumb.js";
5
+ /**
6
+ * Recursively scan `dir` (up to `maxDepth` levels, matching historyList's walk
7
+ * depth) and generate missing `.thumb.jpg` thumbnails for every image and video.
8
+ * Videos and images live both at the top level and inside subdirectories
9
+ * (e.g. video series clips nested one level deep under a date-stamped folder),
10
+ * so a flat readdir misses them — this walks the tree so the gallery never
11
+ * shows a thumbless media tile.
12
+ */
13
+ export async function backfillThumbnails(dir, maxDepth = 2) {
14
+ const result = { total: 0, created: 0, skipped: 0, failed: 0 };
15
+ async function walk(current, depth) {
16
+ const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
17
+ for (const entry of entries) {
18
+ const full = join(current, entry.name);
19
+ if (entry.isDirectory()) {
20
+ if (depth > 0 && entry.name !== "trash")
21
+ await walk(full, depth - 1);
22
+ continue;
23
+ }
24
+ if (!entry.isFile())
25
+ continue;
26
+ if (entry.name.endsWith(".thumb.jpg"))
27
+ continue;
28
+ if (!/\.(png|jpe?g|webp|mp4)$/i.test(entry.name))
29
+ continue;
30
+ result.total++;
31
+ try {
32
+ if (/\.mp4$/i.test(entry.name)) {
33
+ if (await videoThumbExists(full)) {
34
+ result.skipped++;
35
+ continue;
36
+ }
37
+ const ok = await ensureVideoThumbnail(current, entry.name);
38
+ if (ok)
39
+ result.created++;
40
+ else
41
+ result.failed++;
42
+ }
43
+ else {
44
+ if (await imageThumbExists(full)) {
45
+ result.skipped++;
46
+ continue;
47
+ }
48
+ await generateImageThumbnail(full);
49
+ result.created++;
50
+ }
51
+ }
52
+ catch {
53
+ result.failed++;
54
+ }
55
+ }
56
+ }
57
+ await walk(dir, maxDepth);
58
+ return result;
59
+ }
@@ -0,0 +1,44 @@
1
+ import { GoogleAuth } from "google-auth-library";
2
+ let cachedAuth = null;
3
+ let cachedProjectId = null;
4
+ export function initVertexAuth(serviceAccountJson) {
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(serviceAccountJson);
8
+ }
9
+ catch {
10
+ // Never surface the raw JSON (it contains the private key) in the error.
11
+ throw new Error("Invalid service account JSON: could not parse");
12
+ }
13
+ if (!parsed.project_id || parsed.type !== "service_account") {
14
+ throw new Error("Invalid service account JSON: missing project_id or type !== service_account");
15
+ }
16
+ // Build the client first; only commit module state once construction succeeds,
17
+ // so a throw can't leave isVertexInitialized() true with mismatched creds.
18
+ const auth = new GoogleAuth({
19
+ credentials: parsed,
20
+ scopes: ["https://www.googleapis.com/auth/cloud-platform"],
21
+ });
22
+ cachedAuth = auth;
23
+ cachedProjectId = parsed.project_id;
24
+ return { projectId: parsed.project_id };
25
+ }
26
+ export async function getVertexAccessToken() {
27
+ if (!cachedAuth)
28
+ throw new Error("Vertex AI not initialized — call initVertexAuth first");
29
+ const client = await cachedAuth.getClient();
30
+ const tokenRes = await client.getAccessToken();
31
+ if (!tokenRes.token)
32
+ throw new Error("Failed to obtain Vertex AI access token");
33
+ return tokenRes.token;
34
+ }
35
+ export function getVertexProjectId() {
36
+ return cachedProjectId;
37
+ }
38
+ export function isVertexInitialized() {
39
+ return cachedAuth !== null && cachedProjectId !== null;
40
+ }
41
+ export function clearVertexAuth() {
42
+ cachedAuth = null;
43
+ cachedProjectId = null;
44
+ }
@@ -0,0 +1,60 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ const FFMPEG_THUMB_TIMEOUT_MS = 15_000;
7
+ export function thumbPathForVideo(videoPath) {
8
+ return `${videoPath}.thumb.jpg`;
9
+ }
10
+ export function thumbUrlForVideo(videoUrl) {
11
+ return `${videoUrl}.thumb.jpg`;
12
+ }
13
+ export async function generateVideoThumbnail(videoPath) {
14
+ const thumbPath = thumbPathForVideo(videoPath);
15
+ try {
16
+ await execFileAsync("ffmpeg", [
17
+ "-y",
18
+ "-i", videoPath,
19
+ "-vframes", "1",
20
+ "-q:v", "4",
21
+ "-vf", "scale='min(320,iw)':-2",
22
+ thumbPath,
23
+ ], {
24
+ timeout: FFMPEG_THUMB_TIMEOUT_MS,
25
+ killSignal: process.platform === "win32" ? "SIGTERM" : "SIGKILL",
26
+ maxBuffer: 1024 * 1024,
27
+ });
28
+ return thumbPath;
29
+ }
30
+ catch {
31
+ await unlink(thumbPath).catch(() => { });
32
+ throw new Error(`Failed to generate thumbnail for ${videoPath}`);
33
+ }
34
+ }
35
+ export async function ensureVideoThumbnail(generatedDir, filename) {
36
+ const videoPath = join(generatedDir, filename);
37
+ const thumbPath = thumbPathForVideo(videoPath);
38
+ try {
39
+ await stat(thumbPath);
40
+ return true;
41
+ }
42
+ catch {
43
+ try {
44
+ await generateVideoThumbnail(videoPath);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ }
52
+ export async function videoThumbExists(videoFullPath) {
53
+ try {
54
+ await stat(thumbPathForVideo(videoFullPath));
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Local OAuth image generation studio with classic and node workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,8 @@
28
28
  "typecheck": "tsc --noEmit -p tsconfig.json",
29
29
  "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
30
30
  "build:server": "tsc -p tsconfig.build.json",
31
- "build:cli": "tsc -p tsconfig.bin.json && node scripts/fix-shebangs.mjs"
31
+ "build:cli": "tsc -p tsconfig.bin.json && node scripts/fix-shebangs.mjs",
32
+ "postinstall": "echo '\\n [ima2] Gallery thumbnails will be auto-generated on next server start.\\n Or run: ima2 backfill-thumbs\\n'"
32
33
  },
33
34
  "keywords": [
34
35
  "openai",
@@ -67,6 +68,7 @@
67
68
  "better-sqlite3": "^12.9.0",
68
69
  "dotenv": "^17.4.2",
69
70
  "express": "^5.1.0",
71
+ "google-auth-library": "^10.6.2",
70
72
  "openai": "^5.8.2",
71
73
  "openai-oauth": "^1.0.2",
72
74
  "progrok": "file:vendor/progrok-0.2.0.tgz",