ima2-gen 2.0.0 → 2.0.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/CHANGELOG.md +150 -0
- package/README.md +12 -12
- package/bin/commands/backfillThumbs.js +24 -0
- package/bin/commands/edit.js +7 -6
- package/bin/commands/gen.js +13 -6
- package/bin/commands/multimode.js +5 -4
- package/bin/commands/node.js +4 -4
- package/bin/ima2.js +21 -11
- package/bin/lib/config-store.js +1 -1
- package/docs/API.md +184 -10
- package/docs/CLI.md +11 -4
- package/docs/FAQ.ko.md +16 -0
- package/docs/FAQ.md +30 -0
- package/docs/PROMPT_STUDIO.md +3 -1
- package/docs/README.ko.md +7 -3
- package/docs/migration/runtime-test-inventory.md +17 -1
- package/lib/agentImageVideoGen.js +261 -0
- package/lib/agentRuntime.js +11 -260
- package/lib/agentSettings.js +1 -1
- package/lib/agyImageAdapter.js +259 -0
- package/lib/capabilities.js +2 -1
- package/lib/configKeys.js +1 -1
- package/lib/errorClassify.js +8 -7
- package/lib/eventBus.js +71 -0
- package/lib/geminiApiImageAdapter.js +179 -0
- package/lib/generationErrors.js +3 -1
- package/lib/grokImageAdapter.js +74 -128
- package/lib/grokImageCore.js +153 -0
- package/lib/grokMultimodeAdapter.js +7 -4
- package/lib/grokRuntime.js +3 -0
- package/lib/grokSizeMapper.js +13 -1
- package/lib/grokVideoAdapter.js +14 -7
- package/lib/grokVideoCanvas.js +13 -0
- package/lib/grokVideoPlannerPrompt.js +53 -6
- package/lib/historyList.js +19 -2
- package/lib/imageModels.js +15 -0
- package/lib/imageThumb.js +38 -0
- package/lib/inflight.js +54 -17
- package/lib/multimodeHelpers.js +10 -0
- package/lib/nodeHelpers.js +59 -0
- package/lib/oauthProxy/prompts.js +30 -36
- package/lib/promptBuilder/systemPrompt.js +2 -5
- package/lib/promptSafetyPolicy.js +1 -5
- package/lib/providerOptions.js +36 -1
- package/lib/responsesFallback.js +53 -44
- package/lib/routeHelpers.js +44 -0
- package/lib/runtimeContext.js +27 -0
- package/lib/ssePublish.js +12 -0
- package/lib/storageMigration.js +1 -1
- package/lib/storyboardPrefix.js +28 -0
- package/lib/thumbBackfill.js +70 -0
- package/lib/vertexAuth.js +44 -0
- package/lib/videoThumb.js +60 -0
- package/package.json +7 -2
- package/routes/agy.js +44 -0
- package/routes/auth.js +242 -0
- package/routes/edit.js +48 -8
- package/routes/events.js +78 -0
- package/routes/generate.js +135 -135
- package/routes/history.js +13 -0
- package/routes/index.js +8 -0
- package/routes/keys.js +254 -0
- package/routes/multimode.js +138 -62
- package/routes/nodes.js +107 -129
- package/routes/quota.js +58 -7
- package/routes/video.js +107 -20
- package/server.js +123 -0
- package/skills/ima2/SKILL.md +98 -21
- package/ui/dist/.vite/manifest.json +12 -12
- package/ui/dist/assets/AgentWorkspace-Dth6YijN.js +3 -0
- package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dav3K5CT.js} +2 -2
- package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-C4ifFzB1.js} +2 -2
- package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-CEcyU9PL.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-CgQ94Gth.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CuzyzbNI.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-DHLGlO6l.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-BOe18we8.js} +2 -2
- package/ui/dist/assets/SettingsWorkspace-Cdgnm4Wa.js +1 -0
- package/ui/dist/assets/index-C5PSahkr.js +1 -0
- package/ui/dist/assets/index-Dn2AhL6d.css +1 -0
- package/ui/dist/assets/index-Tjqx6wUV.js +23 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
- package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
- package/ui/dist/assets/index-BAFI6htx.js +0 -42
- package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
- package/ui/dist/assets/index-DS-ADE7U.css +0 -1
package/lib/responsesFallback.js
CHANGED
|
@@ -1,57 +1,66 @@
|
|
|
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 = "
|
|
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:
|
|
15
|
+
developerPromptDroppedOnRetry: false,
|
|
15
16
|
webSearchDroppedOnRetry,
|
|
16
17
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
// OAuth/Codex proxy returns empty output[] for non-stream image requests; SSE required.
|
|
42
|
+
stream: true,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (attempt === MAX_RETRIES) {
|
|
48
|
+
if (e && typeof e === "object")
|
|
49
|
+
Object.assign(e, retryMeta);
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
logEvent("oauth", "retry_error", { requestId, attempt, error: e.message, status: e.status, code: e.code });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const image = lastRetry.images[0];
|
|
56
|
+
if (image?.b64) {
|
|
57
|
+
logEvent("oauth", "retry_image", { requestId, retryKind, attempt, imageChars: image.b64.length });
|
|
58
|
+
return { b64: image.b64, usage: lastRetry.usage, webSearchCalls: initial.webSearchCalls, revisedPrompt: image.revisedPrompt, text: lastRetry.text, ...retryMeta };
|
|
59
|
+
}
|
|
60
|
+
logEvent("oauth", "retry_no_image", { requestId, retryKind, attempt, fallbackEventCount: lastRetry.eventCount });
|
|
36
61
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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, {
|
|
62
|
+
const diagSource = lastRetry ?? initial;
|
|
63
|
+
throw emptyResponseError("No image data received after retries", diagSource, {
|
|
55
64
|
provider,
|
|
56
65
|
model,
|
|
57
66
|
quality,
|
|
@@ -64,9 +73,9 @@ export async function retryPromptOnlyJsonImage({ postResponses, ctx, provider, p
|
|
|
64
73
|
toolTypes: ["image_generation"],
|
|
65
74
|
toolChoiceKind: "image_generation",
|
|
66
75
|
...retryMeta,
|
|
67
|
-
fallbackEventCount:
|
|
68
|
-
fallbackEventTypes:
|
|
69
|
-
fallbackImageCallSeen:
|
|
70
|
-
fallbackImageResultCount:
|
|
76
|
+
fallbackEventCount: diagSource.eventCount,
|
|
77
|
+
fallbackEventTypes: diagSource.eventTypes,
|
|
78
|
+
fallbackImageCallSeen: diagSource.diagnostics.imageCallSeen,
|
|
79
|
+
fallbackImageResultCount: diagSource.diagnostics.imageResultCount,
|
|
71
80
|
});
|
|
72
81
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function validateModeration(ctx, moderation) {
|
|
2
|
+
if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
|
|
3
|
+
return { error: "moderation must be one of: auto, low" };
|
|
4
|
+
}
|
|
5
|
+
return { moderation };
|
|
6
|
+
}
|
|
7
|
+
export function imageFormatFromMime(mime) {
|
|
8
|
+
if (mime === "image/jpeg")
|
|
9
|
+
return "jpeg";
|
|
10
|
+
if (mime === "image/webp")
|
|
11
|
+
return "webp";
|
|
12
|
+
return "png";
|
|
13
|
+
}
|
|
14
|
+
export function writeSse(res, event, data) {
|
|
15
|
+
res.write(`event: ${event}\n`);
|
|
16
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
17
|
+
}
|
|
18
|
+
export function dataUrlFromB64(format, b64) {
|
|
19
|
+
return `data:image/${format === "jpeg" ? "jpeg" : format};base64,${b64}`;
|
|
20
|
+
}
|
|
21
|
+
export function upstreamErrorFields(src) {
|
|
22
|
+
return {
|
|
23
|
+
upstreamCode: src.upstreamCode || null,
|
|
24
|
+
upstreamType: src.upstreamType || null,
|
|
25
|
+
upstreamParam: src.upstreamParam || null,
|
|
26
|
+
diagnosticReason: src.diagnosticReason || null,
|
|
27
|
+
retryKind: src.retryKind || null,
|
|
28
|
+
initialEventCount: src.initialEventCount ?? null,
|
|
29
|
+
initialEventTypes: src.initialEventTypes || null,
|
|
30
|
+
referencesDroppedOnRetry: src.referencesDroppedOnRetry ?? null,
|
|
31
|
+
developerPromptDroppedOnRetry: src.developerPromptDroppedOnRetry ?? null,
|
|
32
|
+
webSearchDroppedOnRetry: src.webSearchDroppedOnRetry ?? null,
|
|
33
|
+
fallbackEventCount: src.fallbackEventCount ?? null,
|
|
34
|
+
fallbackEventTypes: src.fallbackEventTypes || null,
|
|
35
|
+
fallbackImageCallSeen: src.fallbackImageCallSeen ?? null,
|
|
36
|
+
fallbackImageResultCount: src.fallbackImageResultCount ?? null,
|
|
37
|
+
errorEventCount: src.eventCount ?? null,
|
|
38
|
+
eventTypes: src.eventTypes || null,
|
|
39
|
+
webSearchCalls: src.webSearchCalls ?? null,
|
|
40
|
+
responseDiagnostics: src.responseDiagnostics || null,
|
|
41
|
+
toolTypes: src.toolTypes || null,
|
|
42
|
+
toolChoiceKind: src.toolChoiceKind || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/lib/runtimeContext.js
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { publish } from "./eventBus.js";
|
|
2
|
+
import { isJobCanceled } from "./inflight.js";
|
|
3
|
+
/**
|
|
4
|
+
* Publish a multiplexed job event. Suppresses terminal `done` after cancel so
|
|
5
|
+
* clients never resolve success when abortJob already emitted `error`.
|
|
6
|
+
*/
|
|
7
|
+
export function publishJobEvent(requestId, event, data) {
|
|
8
|
+
if (event === "done" && isJobCanceled(requestId))
|
|
9
|
+
return false;
|
|
10
|
+
publish(requestId, event, data);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
package/lib/storageMigration.js
CHANGED
|
@@ -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 =
|
|
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,28 @@
|
|
|
1
|
+
export const STORYBOARD_PREFIX = [
|
|
2
|
+
"[STORYBOARD MODE — Video Production Keyframe / Storyboard Grid]",
|
|
3
|
+
"This image will be used for video production. It may be a single keyframe OR a 3x3 storyboard grid.",
|
|
4
|
+
"The prompt and all injected instructions MUST be in English.",
|
|
5
|
+
"",
|
|
6
|
+
"IF GENERATING A 3x3 STORYBOARD GRID:",
|
|
7
|
+
"- Panel 1 (top-left) MUST be COMPLETELY SOLID BLACK — no image, no text, just pure black.",
|
|
8
|
+
"- Panels 2-9 contain the action sequence (8 key moments).",
|
|
9
|
+
"- Do NOT add timestamp labels or text overlays to any panel — they burn into the video.",
|
|
10
|
+
"- Maintain identical character designs across all panels.",
|
|
11
|
+
"- Each panel should look like a cinematic film still, not a sketch.",
|
|
12
|
+
"",
|
|
13
|
+
"CHARACTER LOCK:",
|
|
14
|
+
"- Identify each character by 2-3 VISUAL identifiers (clothing color + physique + position/props). Never by name alone.",
|
|
15
|
+
"- Copy character descriptions VERBATIM from the reference/prior frame. Do NOT rephrase or drift.",
|
|
16
|
+
"",
|
|
17
|
+
"SCENE CONTINUITY:",
|
|
18
|
+
"- Lock lighting direction, color palette, environment, and art style to prior frames.",
|
|
19
|
+
"- Change ONLY: action, shot scale, camera angle, or expression.",
|
|
20
|
+
"- Reference image = canonical anchor. Preserve it faithfully.",
|
|
21
|
+
"",
|
|
22
|
+
"VIDEO-READY COMPOSITION:",
|
|
23
|
+
"- Frame for animation: leave space for motion, avoid static-only poses.",
|
|
24
|
+
"- Use descriptive caption format: shot type + subject action + environment + technical (lens, lighting) + mood.",
|
|
25
|
+
"- Specify intended camera movement for the video phase (e.g. 'slow dolly-in', 'static wide').",
|
|
26
|
+
"- End pose must be stable and suitable for video continuation.",
|
|
27
|
+
"",
|
|
28
|
+
].join("\n") + "\n";
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
const FAILURE_DETAIL_LIMIT = 20;
|
|
6
|
+
function errorReason(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Recursively scan `dir` (up to `maxDepth` levels, matching historyList's walk
|
|
11
|
+
* depth) and generate missing `.thumb.jpg` thumbnails for every image and video.
|
|
12
|
+
* Videos and images live both at the top level and inside subdirectories
|
|
13
|
+
* (e.g. video series clips nested one level deep under a date-stamped folder),
|
|
14
|
+
* so a flat readdir misses them — this walks the tree so the gallery never
|
|
15
|
+
* shows a thumbless media tile.
|
|
16
|
+
*/
|
|
17
|
+
export async function backfillThumbnails(dir, maxDepth = 2) {
|
|
18
|
+
const result = { total: 0, created: 0, skipped: 0, failed: 0, failures: [] };
|
|
19
|
+
function recordFailure(file, kind, reason) {
|
|
20
|
+
result.failed++;
|
|
21
|
+
if (result.failures.length >= FAILURE_DETAIL_LIMIT)
|
|
22
|
+
return;
|
|
23
|
+
result.failures.push({ file, kind, reason });
|
|
24
|
+
}
|
|
25
|
+
async function walk(current, depth) {
|
|
26
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const full = join(current, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (depth > 0 && entry.name !== "trash")
|
|
31
|
+
await walk(full, depth - 1);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!entry.isFile())
|
|
35
|
+
continue;
|
|
36
|
+
if (entry.name.endsWith(".thumb.jpg"))
|
|
37
|
+
continue;
|
|
38
|
+
if (!/\.(png|jpe?g|webp|mp4)$/i.test(entry.name))
|
|
39
|
+
continue;
|
|
40
|
+
result.total++;
|
|
41
|
+
const kind = /\.mp4$/i.test(entry.name) ? "video" : "image";
|
|
42
|
+
try {
|
|
43
|
+
if (kind === "video") {
|
|
44
|
+
if (await videoThumbExists(full)) {
|
|
45
|
+
result.skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const ok = await ensureVideoThumbnail(current, entry.name);
|
|
49
|
+
if (ok)
|
|
50
|
+
result.created++;
|
|
51
|
+
else
|
|
52
|
+
recordFailure(full, kind, "thumbnail generation returned false");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (await imageThumbExists(full)) {
|
|
56
|
+
result.skipped++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
await generateImageThumbnail(full);
|
|
60
|
+
result.created++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
recordFailure(full, kind, errorReason(error));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await walk(dir, maxDepth);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "2.0.2",
|
|
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",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"cli"
|
|
39
40
|
],
|
|
40
41
|
"license": "MIT",
|
|
42
|
+
"homepage": "https://lidge-jun.github.io/ima2-gen/",
|
|
41
43
|
"repository": {
|
|
42
44
|
"type": "git",
|
|
43
45
|
"url": "git+https://github.com/lidge-jun/ima2-gen.git"
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"assets/card-news/templates/",
|
|
57
59
|
".env.example",
|
|
58
60
|
"README.md",
|
|
61
|
+
"CHANGELOG.md",
|
|
59
62
|
"LICENSE",
|
|
60
63
|
"server.js",
|
|
61
64
|
"config.js"
|
|
@@ -64,9 +67,11 @@
|
|
|
64
67
|
"node": ">=20"
|
|
65
68
|
},
|
|
66
69
|
"dependencies": {
|
|
70
|
+
"@openai/codex": "latest",
|
|
67
71
|
"better-sqlite3": "^12.9.0",
|
|
68
72
|
"dotenv": "^17.4.2",
|
|
69
73
|
"express": "^5.1.0",
|
|
74
|
+
"google-auth-library": "^10.6.2",
|
|
70
75
|
"openai": "^5.8.2",
|
|
71
76
|
"openai-oauth": "^1.0.2",
|
|
72
77
|
"progrok": "file:vendor/progrok-0.2.0.tgz",
|
package/routes/agy.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
// Detect whether the Antigravity CLI (`agy`) is installed, using the same
|
|
3
|
+
// spawn-and-catch style as lib/agyImageAdapter.ts (no shell `which`/`where`).
|
|
4
|
+
// Login state cannot be probed — agy has no status command — so we only
|
|
5
|
+
// report installation here.
|
|
6
|
+
function isAgyInstalled() {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let settled = false;
|
|
9
|
+
const done = (value) => {
|
|
10
|
+
if (settled)
|
|
11
|
+
return;
|
|
12
|
+
settled = true;
|
|
13
|
+
resolve(value);
|
|
14
|
+
};
|
|
15
|
+
try {
|
|
16
|
+
const child = spawn("agy", ["--version"], { stdio: "ignore" });
|
|
17
|
+
child.on("error", () => done(false)); // ENOENT when not on PATH
|
|
18
|
+
child.on("exit", (code) => done(code === 0));
|
|
19
|
+
// Safety timeout so a hung binary never blocks the request.
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
try {
|
|
22
|
+
if (!child.killed)
|
|
23
|
+
child.kill();
|
|
24
|
+
}
|
|
25
|
+
catch { /* ignore */ }
|
|
26
|
+
done(false);
|
|
27
|
+
}, 3000).unref?.();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
done(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function registerAgyRoutes(app) {
|
|
35
|
+
app.get("/api/agy/status", async (_req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const installed = await isAgyInstalled();
|
|
38
|
+
res.json({ installed });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
res.json({ installed: false });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|