ima2-gen 1.1.20 → 1.1.21

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 (93) hide show
  1. package/README.md +15 -25
  2. package/bin/commands/capabilities.js +2 -2
  3. package/bin/commands/capabilities.ts +2 -2
  4. package/bin/commands/defaults.js +2 -2
  5. package/bin/commands/defaults.ts +2 -2
  6. package/bin/commands/doctor.js +3 -3
  7. package/bin/commands/doctor.ts +3 -3
  8. package/bin/commands/edit.js +1 -1
  9. package/bin/commands/edit.ts +1 -1
  10. package/bin/commands/gen.js +1 -1
  11. package/bin/commands/gen.ts +1 -1
  12. package/bin/commands/grok.js +16 -11
  13. package/bin/commands/grok.ts +16 -11
  14. package/bin/commands/multimode.js +1 -1
  15. package/bin/commands/multimode.ts +1 -1
  16. package/bin/commands/observability.js +2 -2
  17. package/bin/commands/observability.ts +2 -2
  18. package/bin/commands/video.js +335 -13
  19. package/bin/commands/video.ts +249 -12
  20. package/bin/ima2.js +9 -9
  21. package/bin/ima2.ts +9 -9
  22. package/bin/lib/error-hints.js +2 -2
  23. package/bin/lib/error-hints.ts +2 -2
  24. package/docs/API.md +112 -3
  25. package/docs/CLI.md +61 -7
  26. package/docs/FAQ.ko.md +15 -20
  27. package/docs/FAQ.md +14 -19
  28. package/docs/NPX_QUICKSTART.md +40 -0
  29. package/docs/PROMPT_STUDIO.ko.md +1 -1
  30. package/docs/PROMPT_STUDIO.md +1 -1
  31. package/docs/README.ja.md +6 -16
  32. package/docs/README.ko.md +10 -20
  33. package/docs/README.zh-CN.md +7 -17
  34. package/docs/migration/runtime-test-inventory.md +8 -1
  35. package/lib/agentRuntime.js +19 -5
  36. package/lib/agentRuntime.ts +17 -5
  37. package/lib/capabilities.js +1 -1
  38. package/lib/capabilities.ts +1 -1
  39. package/lib/generationErrors.js +1 -1
  40. package/lib/generationErrors.ts +1 -1
  41. package/lib/grokProxyLauncher.js +26 -3
  42. package/lib/grokProxyLauncher.ts +27 -3
  43. package/lib/grokVideoAdapter.js +18 -89
  44. package/lib/grokVideoAdapter.ts +27 -88
  45. package/lib/grokVideoCanvas.js +25 -0
  46. package/lib/grokVideoCanvas.ts +26 -0
  47. package/lib/grokVideoDownload.js +58 -0
  48. package/lib/grokVideoDownload.ts +59 -0
  49. package/lib/grokVideoPlannerPrompt.js +64 -0
  50. package/lib/grokVideoPlannerPrompt.ts +67 -0
  51. package/lib/historyList.js +7 -1
  52. package/lib/historyList.ts +5 -1
  53. package/lib/oauthLauncher.js +21 -6
  54. package/lib/oauthLauncher.ts +22 -6
  55. package/lib/videoContinuity.js +149 -0
  56. package/lib/videoContinuity.ts +180 -0
  57. package/lib/videoFrameExtract.js +80 -0
  58. package/lib/videoFrameExtract.ts +78 -0
  59. package/node_modules/progrok/dist/index.js +187 -88
  60. package/node_modules/progrok/dist/index.js.map +1 -1
  61. package/node_modules/progrok/package.json +1 -1
  62. package/node_modules/progrok/skills/progrok/SKILL.md +33 -4
  63. package/package.json +2 -2
  64. package/routes/index.js +4 -0
  65. package/routes/index.ts +4 -0
  66. package/routes/quota.js +66 -0
  67. package/routes/quota.ts +89 -0
  68. package/routes/video.js +77 -15
  69. package/routes/video.ts +82 -14
  70. package/routes/videoExtended.js +293 -0
  71. package/routes/videoExtended.ts +284 -0
  72. package/server.js +6 -2
  73. package/server.ts +5 -2
  74. package/skills/ima2/SKILL.md +320 -7
  75. package/ui/dist/.vite/manifest.json +12 -12
  76. package/ui/dist/assets/{AgentWorkspace-DS8uvoLI.js → AgentWorkspace-B_hq9CLg.js} +2 -2
  77. package/ui/dist/assets/{CardNewsWorkspace-CYxMsE67.js → CardNewsWorkspace-wD12J7qk.js} +1 -1
  78. package/ui/dist/assets/{NodeCanvas-DccIc347.js → NodeCanvas-CI_wuPMf.js} +1 -1
  79. package/ui/dist/assets/{PromptBuilderPanel-BvxxwSJp.js → PromptBuilderPanel-CUTujJUV.js} +1 -1
  80. package/ui/dist/assets/{PromptImportDialog-u1_BFDRd.js → PromptImportDialog-CUi66jPK.js} +2 -2
  81. package/ui/dist/assets/{PromptImportDiscoverySection-C5uvkVSz.js → PromptImportDiscoverySection-Cm3vrjY4.js} +1 -1
  82. package/ui/dist/assets/{PromptImportFolderSection-D3E_O1SD.js → PromptImportFolderSection-DOtWTD9n.js} +1 -1
  83. package/ui/dist/assets/{PromptLibraryPanel-4gyf9CB9.js → PromptLibraryPanel-BMjQegRa.js} +2 -2
  84. package/ui/dist/assets/SettingsWorkspace-PiaVnsdA.js +1 -0
  85. package/ui/dist/assets/{index-DoKtXbod.js → index-31uVIdt4.js} +1 -1
  86. package/ui/dist/assets/index-CjgnNtgt.css +1 -0
  87. package/ui/dist/assets/index-Da2s4_-5.js +36 -0
  88. package/ui/dist/index.html +2 -2
  89. package/vendor/progrok-0.2.0.tgz +0 -0
  90. package/ui/dist/assets/SettingsWorkspace-F3eNu3mJ.js +0 -1
  91. package/ui/dist/assets/index-B6tcw_UF.css +0 -1
  92. package/ui/dist/assets/index-DYOh6gQD.js +0 -32
  93. package/vendor/progrok-0.1.1.tgz +0 -0
@@ -1,6 +1,7 @@
1
1
  import { isWin } from "../bin/lib/platform.js";
2
2
  import { config } from "../config.js";
3
3
  import { parseLocalhostPortFromUrl, parseOAuthReadyUrl } from "./runtimePorts.js";
4
+ import { hasAuthFile } from "./codexDetect.js";
4
5
  import { spawn } from "node:child_process";
5
6
  export function startOAuthProxy(options = {}) {
6
7
  const oauthPort = options.oauthPort ?? config.oauth.proxyPort;
@@ -9,8 +10,17 @@ export function startOAuthProxy(options = {}) {
9
10
  let stopping = false;
10
11
  let restartTimer = null;
11
12
  let hasBeenReady = false;
13
+ let restartCount = 0;
14
+ const MAX_RESTARTS = 3;
12
15
  const spawnProxy = () => {
13
- console.log(`Starting openai-oauth on port ${oauthPort}...`);
16
+ // Guard: don't start if no auth file exists (avoids pointless crash loops
17
+ // and prevents openai-oauth from corrupting state on refresh failure)
18
+ if (!hasAuthFile()) {
19
+ console.log("[gpt-oauth] No Codex auth file found. Skipping GPT OAuth proxy.");
20
+ options.onExit?.({ code: 0 });
21
+ return;
22
+ }
23
+ console.log(`Starting GPT OAuth proxy (openai-oauth) on port ${oauthPort}...`);
14
24
  const spawnedAt = Date.now();
15
25
  const child = spawn("npx", ["openai-oauth", "--port", String(oauthPort)], {
16
26
  stdio: ["ignore", "pipe", "pipe"],
@@ -23,14 +33,14 @@ export function startOAuthProxy(options = {}) {
23
33
  const msg = d.toString().trim();
24
34
  if (!msg)
25
35
  return;
26
- console.log(`[oauth] ${msg}`);
36
+ console.log(`[gpt-oauth] ${msg}`);
27
37
  for (const line of msg.split(/\r?\n/)) {
28
38
  const url = parseOAuthReadyUrl(line);
29
39
  if (!url)
30
40
  continue;
31
41
  const port = parseLocalhostPortFromUrl(url);
32
42
  if (port && port !== oauthPort) {
33
- console.log(`[oauth] requested port ${oauthPort}, actual port ${port}`);
43
+ console.log(`[gpt-oauth] requested port ${oauthPort}, actual port ${port}`);
34
44
  }
35
45
  options.onReady?.({ url, port: port || oauthPort, requestedPort: oauthPort });
36
46
  hasBeenReady = true;
@@ -39,7 +49,7 @@ export function startOAuthProxy(options = {}) {
39
49
  child.stderr?.on("data", (d) => {
40
50
  const msg = d.toString().trim();
41
51
  if (msg && !msg.includes("npm warn"))
42
- console.error(`[oauth] ${msg}`);
52
+ console.error(`[gpt-oauth] ${msg}`);
43
53
  });
44
54
  child.on("exit", (code) => {
45
55
  if (currentChild === child)
@@ -50,12 +60,17 @@ export function startOAuthProxy(options = {}) {
50
60
  if (uptime < 5000 && !hasBeenReady) {
51
61
  // Crashed immediately without ever becoming ready — likely missing openai-oauth or no token.
52
62
  // Don't restart; just mark as failed silently.
53
- console.log(`[oauth] proxy exited immediately (code ${code}). Skipping — Grok-only mode is fine.`);
63
+ console.log(`[gpt-oauth] proxy exited immediately (code ${code}). Skipping — Grok-only mode is fine.`);
54
64
  options.onExit?.({ code });
55
65
  return;
56
66
  }
57
67
  options.onExit?.({ code });
58
- console.log(`[oauth] exited with code ${code}, restarting in ${Math.round(restartDelayMs / 1000)}s...`);
68
+ if (restartCount >= MAX_RESTARTS) {
69
+ console.log(`[gpt-oauth] max restarts (${MAX_RESTARTS}) reached. Giving up — Grok-only mode is fine.`);
70
+ return;
71
+ }
72
+ restartCount++;
73
+ console.log(`[gpt-oauth] exited with code ${code}, restarting in ${Math.round(restartDelayMs / 1000)}s... (attempt ${restartCount}/${MAX_RESTARTS})`);
59
74
  restartTimer = setTimeout(spawnProxy, restartDelayMs);
60
75
  });
61
76
  };
@@ -1,6 +1,7 @@
1
1
  import { isWin } from "../bin/lib/platform.js";
2
2
  import { config } from "../config.js";
3
3
  import { parseLocalhostPortFromUrl, parseOAuthReadyUrl } from "./runtimePorts.js";
4
+ import { hasAuthFile } from "./codexDetect.js";
4
5
  import { type ChildProcess, spawn } from "node:child_process";
5
6
 
6
7
  export function startOAuthProxy(options: any = {}) {
@@ -10,9 +11,19 @@ export function startOAuthProxy(options: any = {}) {
10
11
  let stopping = false;
11
12
  let restartTimer: NodeJS.Timeout | null = null;
12
13
  let hasBeenReady = false;
14
+ let restartCount = 0;
15
+ const MAX_RESTARTS = 3;
13
16
 
14
17
  const spawnProxy = () => {
15
- console.log(`Starting openai-oauth on port ${oauthPort}...`);
18
+ // Guard: don't start if no auth file exists (avoids pointless crash loops
19
+ // and prevents openai-oauth from corrupting state on refresh failure)
20
+ if (!hasAuthFile()) {
21
+ console.log("[gpt-oauth] No Codex auth file found. Skipping GPT OAuth proxy.");
22
+ options.onExit?.({ code: 0 });
23
+ return;
24
+ }
25
+
26
+ console.log(`Starting GPT OAuth proxy (openai-oauth) on port ${oauthPort}...`);
16
27
  const spawnedAt = Date.now();
17
28
  const child = spawn("npx", ["openai-oauth", "--port", String(oauthPort)], {
18
29
  stdio: ["ignore", "pipe", "pipe"],
@@ -25,13 +36,13 @@ export function startOAuthProxy(options: any = {}) {
25
36
  child.stdout?.on("data", (d) => {
26
37
  const msg = d.toString().trim();
27
38
  if (!msg) return;
28
- console.log(`[oauth] ${msg}`);
39
+ console.log(`[gpt-oauth] ${msg}`);
29
40
  for (const line of msg.split(/\r?\n/)) {
30
41
  const url = parseOAuthReadyUrl(line);
31
42
  if (!url) continue;
32
43
  const port = parseLocalhostPortFromUrl(url);
33
44
  if (port && port !== oauthPort) {
34
- console.log(`[oauth] requested port ${oauthPort}, actual port ${port}`);
45
+ console.log(`[gpt-oauth] requested port ${oauthPort}, actual port ${port}`);
35
46
  }
36
47
  options.onReady?.({ url, port: port || oauthPort, requestedPort: oauthPort });
37
48
  hasBeenReady = true;
@@ -40,7 +51,7 @@ export function startOAuthProxy(options: any = {}) {
40
51
 
41
52
  child.stderr?.on("data", (d) => {
42
53
  const msg = d.toString().trim();
43
- if (msg && !msg.includes("npm warn")) console.error(`[oauth] ${msg}`);
54
+ if (msg && !msg.includes("npm warn")) console.error(`[gpt-oauth] ${msg}`);
44
55
  });
45
56
 
46
57
  child.on("exit", (code) => {
@@ -50,12 +61,17 @@ export function startOAuthProxy(options: any = {}) {
50
61
  if (uptime < 5000 && !hasBeenReady) {
51
62
  // Crashed immediately without ever becoming ready — likely missing openai-oauth or no token.
52
63
  // Don't restart; just mark as failed silently.
53
- console.log(`[oauth] proxy exited immediately (code ${code}). Skipping — Grok-only mode is fine.`);
64
+ console.log(`[gpt-oauth] proxy exited immediately (code ${code}). Skipping — Grok-only mode is fine.`);
54
65
  options.onExit?.({ code });
55
66
  return;
56
67
  }
57
68
  options.onExit?.({ code });
58
- console.log(`[oauth] exited with code ${code}, restarting in ${Math.round(restartDelayMs / 1000)}s...`);
69
+ if (restartCount >= MAX_RESTARTS) {
70
+ console.log(`[gpt-oauth] max restarts (${MAX_RESTARTS}) reached. Giving up — Grok-only mode is fine.`);
71
+ return;
72
+ }
73
+ restartCount++;
74
+ console.log(`[gpt-oauth] exited with code ${code}, restarting in ${Math.round(restartDelayMs / 1000)}s... (attempt ${restartCount}/${MAX_RESTARTS})`);
59
75
  restartTimer = setTimeout(spawnProxy, restartDelayMs);
60
76
  });
61
77
  };
@@ -0,0 +1,149 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ export const ACTIVE_VIDEO_PROMPT_GUIDANCE = [
4
+ "Active video prompt required.",
5
+ "Describe visual flow, motion flow, sound or no-music intent, dialogue or no-dialogue intent, and the desired ending frame.",
6
+ "Pace the scene to naturally fill the selected duration, expanding even short requests into an opening composition, connected motion/emotion change, and stable ending frame.",
7
+ "Example: From the attached last frame, the subject turns toward camera, rain sound rises, no background music, one whispered line finishes before a still close-up ending.",
8
+ ].join(" ");
9
+ export function requireActiveVideoPrompt(value) {
10
+ return typeof value === "string" && value.trim() ? value.trim() : null;
11
+ }
12
+ export function safeGeneratedVideoFilename(value) {
13
+ if (typeof value !== "string" || !value.trim())
14
+ throw Object.assign(new Error("video filename required"), { status: 400 });
15
+ const clean = value.replace(/^\/generated\//, "").replace(/^\/+/, "");
16
+ if (clean.includes("..") || clean.includes("/") || clean.includes("\\")) {
17
+ throw Object.assign(new Error("invalid video filename"), { status: 400 });
18
+ }
19
+ if (!/\.mp4$/i.test(clean))
20
+ throw Object.assign(new Error("generated video input must be an .mp4 file"), { status: 400 });
21
+ return clean;
22
+ }
23
+ export async function readVideoSidecar(generatedDir, filename) {
24
+ const safe = safeGeneratedVideoFilename(filename);
25
+ try {
26
+ return JSON.parse(await readFile(join(generatedDir, `${safe}.json`), "utf-8"));
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function stringOrNull(value) {
33
+ return typeof value === "string" && value.trim() ? value.trim() : null;
34
+ }
35
+ function numberOrNow(value) {
36
+ return typeof value === "number" && Number.isFinite(value) ? value : Date.now();
37
+ }
38
+ function entryFromMeta(filename, meta) {
39
+ const revisedPrompt = stringOrNull(meta?.revisedPrompt) ?? stringOrNull(meta?.prompt);
40
+ if (!revisedPrompt)
41
+ return null;
42
+ return {
43
+ id: `clip:${filename}`,
44
+ ordinal: 1,
45
+ role: "start",
46
+ filename,
47
+ userPrompt: stringOrNull(meta?.userPrompt) ?? stringOrNull(meta?.prompt),
48
+ revisedPrompt,
49
+ createdAt: numberOrNow(meta?.createdAt),
50
+ };
51
+ }
52
+ export function normalizeVideoContinuityLineage(value) {
53
+ if (!value || typeof value !== "object")
54
+ return null;
55
+ const raw = value;
56
+ if (!Array.isArray(raw.entries))
57
+ return null;
58
+ const entries = raw.entries
59
+ .map((entry, index) => {
60
+ if (!entry || typeof entry !== "object")
61
+ return null;
62
+ const e = entry;
63
+ const revisedPrompt = stringOrNull(e.revisedPrompt);
64
+ if (!revisedPrompt)
65
+ return null;
66
+ return {
67
+ id: stringOrNull(e.id) ?? `entry:${index + 1}`,
68
+ ordinal: index + 1,
69
+ role: index === 0 ? "start" : index === raw.entries.length - 1 ? "parent" : "ancestor",
70
+ filename: stringOrNull(e.filename),
71
+ userPrompt: stringOrNull(e.userPrompt),
72
+ revisedPrompt,
73
+ createdAt: numberOrNow(e.createdAt),
74
+ };
75
+ })
76
+ .filter((entry) => Boolean(entry));
77
+ if (entries.length === 0)
78
+ return null;
79
+ return {
80
+ lineageId: stringOrNull(raw.lineageId) ?? `lineage:${entries[0].id}`,
81
+ parentFilename: stringOrNull(raw.parentFilename),
82
+ sourceFrame: "last",
83
+ maxEntries: 4,
84
+ retention: "keep-start-plus-latest-3",
85
+ entries: trimLineageEntries(entries),
86
+ };
87
+ }
88
+ export function trimLineageEntries(entries) {
89
+ const kept = entries.length <= 4 ? entries : [entries[0], ...entries.slice(-3)];
90
+ return kept.map((entry, index) => ({
91
+ ...entry,
92
+ ordinal: index + 1,
93
+ role: index === 0 ? "start" : index === kept.length - 1 ? entry.role : "ancestor",
94
+ }));
95
+ }
96
+ export function lineageFromVideoMetadata(filename, meta) {
97
+ const existing = normalizeVideoContinuityLineage(meta?.videoContinuity);
98
+ if (existing) {
99
+ return { ...existing, parentFilename: filename, sourceFrame: "last" };
100
+ }
101
+ const entry = entryFromMeta(filename, meta);
102
+ if (!entry)
103
+ return null;
104
+ return {
105
+ lineageId: `lineage:${filename.replace(/\.[^.]+$/, "")}`,
106
+ parentFilename: filename,
107
+ sourceFrame: "last",
108
+ maxEntries: 4,
109
+ retention: "keep-start-plus-latest-3",
110
+ entries: [entry],
111
+ };
112
+ }
113
+ export function appendVideoContinuityEntry(parent, current) {
114
+ const parentEntries = parent?.entries ?? [];
115
+ const lineageId = parent?.lineageId ?? `lineage:${current.filename.replace(/\.[^.]+$/, "")}`;
116
+ const currentEntry = {
117
+ id: `clip:${current.filename}`,
118
+ ordinal: parentEntries.length + 1,
119
+ role: "current",
120
+ filename: current.filename,
121
+ userPrompt: current.userPrompt,
122
+ revisedPrompt: current.revisedPrompt,
123
+ createdAt: current.createdAt ?? Date.now(),
124
+ };
125
+ const entries = trimLineageEntries([...parentEntries.map((entry) => ({ ...entry, role: entry.role === "current" ? "parent" : entry.role })), currentEntry]);
126
+ return {
127
+ lineageId,
128
+ parentFilename: parent?.parentFilename ?? null,
129
+ sourceFrame: parent ? "last" : null,
130
+ maxEntries: 4,
131
+ retention: "keep-start-plus-latest-3",
132
+ entries,
133
+ };
134
+ }
135
+ export function formatVideoContinuityForPlanner(lineage) {
136
+ if (!lineage?.entries?.length)
137
+ return "";
138
+ const lines = [
139
+ "[Continuity lineage: branch-local, max 4 entries, start anchor preserved]",
140
+ ...lineage.entries.map((entry) => [
141
+ `${entry.ordinal}. Clip ${entry.ordinal} / ${entry.role}`,
142
+ ` file: ${entry.filename ? basename(entry.filename) : "unknown"}`,
143
+ ` revisedPrompt: ${entry.revisedPrompt}`,
144
+ entry.userPrompt ? ` userPrompt: ${entry.userPrompt}` : null,
145
+ ].filter(Boolean).join("\n")),
146
+ "Continue from the final frame and final action/audio state of the latest lineage item. Do not restart the scene.",
147
+ ];
148
+ return lines.join("\n");
149
+ }
@@ -0,0 +1,180 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+
4
+ export const ACTIVE_VIDEO_PROMPT_GUIDANCE = [
5
+ "Active video prompt required.",
6
+ "Describe visual flow, motion flow, sound or no-music intent, dialogue or no-dialogue intent, and the desired ending frame.",
7
+ "Pace the scene to naturally fill the selected duration, expanding even short requests into an opening composition, connected motion/emotion change, and stable ending frame.",
8
+ "Example: From the attached last frame, the subject turns toward camera, rain sound rises, no background music, one whispered line finishes before a still close-up ending.",
9
+ ].join(" ");
10
+
11
+ export type VideoContinuityEntry = {
12
+ id: string;
13
+ ordinal: number;
14
+ role: "start" | "ancestor" | "parent" | "current";
15
+ filename: string | null;
16
+ userPrompt: string | null;
17
+ revisedPrompt: string;
18
+ createdAt: number;
19
+ };
20
+
21
+ export type VideoContinuityLineage = {
22
+ lineageId: string;
23
+ parentFilename: string | null;
24
+ sourceFrame: "last" | null;
25
+ maxEntries: 4;
26
+ retention: "keep-start-plus-latest-3";
27
+ entries: VideoContinuityEntry[];
28
+ };
29
+
30
+ type VideoMeta = {
31
+ prompt?: unknown;
32
+ userPrompt?: unknown;
33
+ revisedPrompt?: unknown;
34
+ createdAt?: unknown;
35
+ videoContinuity?: unknown;
36
+ };
37
+
38
+ export function requireActiveVideoPrompt(value: unknown): string | null {
39
+ return typeof value === "string" && value.trim() ? value.trim() : null;
40
+ }
41
+
42
+ export function safeGeneratedVideoFilename(value: unknown): string {
43
+ if (typeof value !== "string" || !value.trim()) throw Object.assign(new Error("video filename required"), { status: 400 });
44
+ const clean = value.replace(/^\/generated\//, "").replace(/^\/+/, "");
45
+ if (clean.includes("..") || clean.includes("/") || clean.includes("\\")) {
46
+ throw Object.assign(new Error("invalid video filename"), { status: 400 });
47
+ }
48
+ if (!/\.mp4$/i.test(clean)) throw Object.assign(new Error("generated video input must be an .mp4 file"), { status: 400 });
49
+ return clean;
50
+ }
51
+
52
+ export async function readVideoSidecar(generatedDir: string, filename: string): Promise<VideoMeta | null> {
53
+ const safe = safeGeneratedVideoFilename(filename);
54
+ try {
55
+ return JSON.parse(await readFile(join(generatedDir, `${safe}.json`), "utf-8")) as VideoMeta;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function stringOrNull(value: unknown): string | null {
62
+ return typeof value === "string" && value.trim() ? value.trim() : null;
63
+ }
64
+
65
+ function numberOrNow(value: unknown): number {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : Date.now();
67
+ }
68
+
69
+ function entryFromMeta(filename: string, meta: VideoMeta | null): VideoContinuityEntry | null {
70
+ const revisedPrompt = stringOrNull(meta?.revisedPrompt) ?? stringOrNull(meta?.prompt);
71
+ if (!revisedPrompt) return null;
72
+ return {
73
+ id: `clip:${filename}`,
74
+ ordinal: 1,
75
+ role: "start",
76
+ filename,
77
+ userPrompt: stringOrNull(meta?.userPrompt) ?? stringOrNull(meta?.prompt),
78
+ revisedPrompt,
79
+ createdAt: numberOrNow(meta?.createdAt),
80
+ };
81
+ }
82
+
83
+ export function normalizeVideoContinuityLineage(value: unknown): VideoContinuityLineage | null {
84
+ if (!value || typeof value !== "object") return null;
85
+ const raw = value as Partial<VideoContinuityLineage>;
86
+ if (!Array.isArray(raw.entries)) return null;
87
+ const entries = raw.entries
88
+ .map((entry, index): VideoContinuityEntry | null => {
89
+ if (!entry || typeof entry !== "object") return null;
90
+ const e = entry as Partial<VideoContinuityEntry>;
91
+ const revisedPrompt = stringOrNull(e.revisedPrompt);
92
+ if (!revisedPrompt) return null;
93
+ return {
94
+ id: stringOrNull(e.id) ?? `entry:${index + 1}`,
95
+ ordinal: index + 1,
96
+ role: index === 0 ? "start" : index === raw.entries!.length - 1 ? "parent" : "ancestor",
97
+ filename: stringOrNull(e.filename),
98
+ userPrompt: stringOrNull(e.userPrompt),
99
+ revisedPrompt,
100
+ createdAt: numberOrNow(e.createdAt),
101
+ };
102
+ })
103
+ .filter((entry): entry is VideoContinuityEntry => Boolean(entry));
104
+ if (entries.length === 0) return null;
105
+ return {
106
+ lineageId: stringOrNull(raw.lineageId) ?? `lineage:${entries[0].id}`,
107
+ parentFilename: stringOrNull(raw.parentFilename),
108
+ sourceFrame: "last",
109
+ maxEntries: 4,
110
+ retention: "keep-start-plus-latest-3",
111
+ entries: trimLineageEntries(entries),
112
+ };
113
+ }
114
+
115
+ export function trimLineageEntries(entries: VideoContinuityEntry[]): VideoContinuityEntry[] {
116
+ const kept = entries.length <= 4 ? entries : [entries[0], ...entries.slice(-3)];
117
+ return kept.map((entry, index) => ({
118
+ ...entry,
119
+ ordinal: index + 1,
120
+ role: index === 0 ? "start" : index === kept.length - 1 ? entry.role : "ancestor",
121
+ }));
122
+ }
123
+
124
+ export function lineageFromVideoMetadata(filename: string, meta: VideoMeta | null): VideoContinuityLineage | null {
125
+ const existing = normalizeVideoContinuityLineage(meta?.videoContinuity);
126
+ if (existing) {
127
+ return { ...existing, parentFilename: filename, sourceFrame: "last" };
128
+ }
129
+ const entry = entryFromMeta(filename, meta);
130
+ if (!entry) return null;
131
+ return {
132
+ lineageId: `lineage:${filename.replace(/\.[^.]+$/, "")}`,
133
+ parentFilename: filename,
134
+ sourceFrame: "last",
135
+ maxEntries: 4,
136
+ retention: "keep-start-plus-latest-3",
137
+ entries: [entry],
138
+ };
139
+ }
140
+
141
+ export function appendVideoContinuityEntry(
142
+ parent: VideoContinuityLineage | null,
143
+ current: { filename: string; userPrompt: string | null; revisedPrompt: string; createdAt?: number },
144
+ ): VideoContinuityLineage {
145
+ const parentEntries = parent?.entries ?? [];
146
+ const lineageId = parent?.lineageId ?? `lineage:${current.filename.replace(/\.[^.]+$/, "")}`;
147
+ const currentEntry: VideoContinuityEntry = {
148
+ id: `clip:${current.filename}`,
149
+ ordinal: parentEntries.length + 1,
150
+ role: "current",
151
+ filename: current.filename,
152
+ userPrompt: current.userPrompt,
153
+ revisedPrompt: current.revisedPrompt,
154
+ createdAt: current.createdAt ?? Date.now(),
155
+ };
156
+ const entries = trimLineageEntries([...parentEntries.map((entry) => ({ ...entry, role: entry.role === "current" ? "parent" as const : entry.role })), currentEntry]);
157
+ return {
158
+ lineageId,
159
+ parentFilename: parent?.parentFilename ?? null,
160
+ sourceFrame: parent ? "last" : null,
161
+ maxEntries: 4,
162
+ retention: "keep-start-plus-latest-3",
163
+ entries,
164
+ };
165
+ }
166
+
167
+ export function formatVideoContinuityForPlanner(lineage: VideoContinuityLineage | null | undefined): string {
168
+ if (!lineage?.entries?.length) return "";
169
+ const lines = [
170
+ "[Continuity lineage: branch-local, max 4 entries, start anchor preserved]",
171
+ ...lineage.entries.map((entry) => [
172
+ `${entry.ordinal}. Clip ${entry.ordinal} / ${entry.role}`,
173
+ ` file: ${entry.filename ? basename(entry.filename) : "unknown"}`,
174
+ ` revisedPrompt: ${entry.revisedPrompt}`,
175
+ entry.userPrompt ? ` userPrompt: ${entry.userPrompt}` : null,
176
+ ].filter(Boolean).join("\n")),
177
+ "Continue from the final frame and final action/audio state of the latest lineage item. Do not restart the scene.",
178
+ ];
179
+ return lines.join("\n");
180
+ }
@@ -0,0 +1,80 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { open, readFile, realpath, stat, unlink } from "node:fs/promises";
4
+ import { extname, join, resolve, sep } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const MAX_LOCAL_VIDEO_BYTES = 100 * 1024 * 1024;
8
+ const MAX_FRAME_POSITION_SECONDS = 60 * 60;
9
+ const FFMPEG_TIMEOUT_MS = 30_000;
10
+ function routeError(message, status = 400) {
11
+ return Object.assign(new Error(message), { status });
12
+ }
13
+ export async function safeGeneratedFilePath(generatedDir, file, options = {}) {
14
+ const base = resolve(generatedDir);
15
+ const target = file.startsWith("/") ? resolve(file) : resolve(base, file);
16
+ if (target !== base && !target.startsWith(`${base}${sep}`)) {
17
+ throw routeError("invalid file path", 400);
18
+ }
19
+ let baseReal;
20
+ let targetReal;
21
+ try {
22
+ baseReal = await realpath(base);
23
+ targetReal = await realpath(target);
24
+ }
25
+ catch {
26
+ throw routeError("video file not found", 404);
27
+ }
28
+ if (targetReal !== baseReal && !targetReal.startsWith(`${baseReal}${sep}`)) {
29
+ throw routeError("invalid file path", 400);
30
+ }
31
+ if (options.requireMp4 && extname(targetReal).toLowerCase() !== ".mp4") {
32
+ throw routeError("generated video input must be an .mp4 file", 400);
33
+ }
34
+ return targetReal;
35
+ }
36
+ export async function assertLocalMp4(path) {
37
+ const info = await stat(path);
38
+ if (!info.isFile())
39
+ throw routeError("generated video input must be a file", 400);
40
+ if (info.size <= 0)
41
+ throw routeError("generated video input is empty", 400);
42
+ if (info.size > MAX_LOCAL_VIDEO_BYTES)
43
+ throw routeError("generated video input exceeds the 100MB limit", 400);
44
+ const fh = await open(path, "r");
45
+ try {
46
+ const header = Buffer.alloc(12);
47
+ const { bytesRead } = await fh.read(header, 0, header.length, 0);
48
+ if (bytesRead < 12 || header.subarray(4, 8).toString("ascii") !== "ftyp") {
49
+ throw routeError("generated video input must be an MP4 container", 400);
50
+ }
51
+ }
52
+ finally {
53
+ await fh.close();
54
+ }
55
+ }
56
+ export async function extractVideoFrame(input, output, position) {
57
+ const options = { timeout: FFMPEG_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: 1024 * 1024 };
58
+ if (position === "last") {
59
+ await execFileAsync("ffmpeg", ["-y", "-sseof", "-3", "-i", input, "-update", "1", "-q:v", "1", output], options);
60
+ return;
61
+ }
62
+ const sec = Number(position);
63
+ if (!Number.isFinite(sec) || sec < 0)
64
+ throw new Error("position must be a non-negative number or 'last'");
65
+ if (sec > MAX_FRAME_POSITION_SECONDS)
66
+ throw new Error("position exceeds the maximum supported seek time");
67
+ await execFileAsync("ffmpeg", ["-y", "-ss", String(sec), "-i", input, "-vframes", "1", output], options);
68
+ }
69
+ export async function extractGeneratedVideoFrameB64(generatedDir, filename, position = "last") {
70
+ const inputPath = await safeGeneratedFilePath(generatedDir, filename, { requireMp4: true });
71
+ await assertLocalMp4(inputPath);
72
+ const tmpOut = join(generatedDir, `frame_tmp_${randomBytes(4).toString("hex")}.png`);
73
+ try {
74
+ await extractVideoFrame(inputPath, tmpOut, position);
75
+ return (await readFile(tmpOut)).toString("base64");
76
+ }
77
+ finally {
78
+ await unlink(tmpOut).catch(() => { });
79
+ }
80
+ }
@@ -0,0 +1,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { open, readFile, realpath, stat, unlink } from "node:fs/promises";
4
+ import { extname, join, resolve, sep } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const MAX_LOCAL_VIDEO_BYTES = 100 * 1024 * 1024;
9
+ const MAX_FRAME_POSITION_SECONDS = 60 * 60;
10
+ const FFMPEG_TIMEOUT_MS = 30_000;
11
+
12
+ function routeError(message: string, status = 400): Error & { status: number } {
13
+ return Object.assign(new Error(message), { status });
14
+ }
15
+
16
+ export async function safeGeneratedFilePath(generatedDir: string, file: string, options: { requireMp4?: boolean } = {}): Promise<string> {
17
+ const base = resolve(generatedDir);
18
+ const target = file.startsWith("/") ? resolve(file) : resolve(base, file);
19
+ if (target !== base && !target.startsWith(`${base}${sep}`)) {
20
+ throw routeError("invalid file path", 400);
21
+ }
22
+ let baseReal: string;
23
+ let targetReal: string;
24
+ try {
25
+ baseReal = await realpath(base);
26
+ targetReal = await realpath(target);
27
+ } catch {
28
+ throw routeError("video file not found", 404);
29
+ }
30
+ if (targetReal !== baseReal && !targetReal.startsWith(`${baseReal}${sep}`)) {
31
+ throw routeError("invalid file path", 400);
32
+ }
33
+ if (options.requireMp4 && extname(targetReal).toLowerCase() !== ".mp4") {
34
+ throw routeError("generated video input must be an .mp4 file", 400);
35
+ }
36
+ return targetReal;
37
+ }
38
+
39
+ export async function assertLocalMp4(path: string): Promise<void> {
40
+ const info = await stat(path);
41
+ if (!info.isFile()) throw routeError("generated video input must be a file", 400);
42
+ if (info.size <= 0) throw routeError("generated video input is empty", 400);
43
+ if (info.size > MAX_LOCAL_VIDEO_BYTES) throw routeError("generated video input exceeds the 100MB limit", 400);
44
+ const fh = await open(path, "r");
45
+ try {
46
+ const header = Buffer.alloc(12);
47
+ const { bytesRead } = await fh.read(header, 0, header.length, 0);
48
+ if (bytesRead < 12 || header.subarray(4, 8).toString("ascii") !== "ftyp") {
49
+ throw routeError("generated video input must be an MP4 container", 400);
50
+ }
51
+ } finally {
52
+ await fh.close();
53
+ }
54
+ }
55
+
56
+ export async function extractVideoFrame(input: string, output: string, position: string): Promise<void> {
57
+ const options = { timeout: FFMPEG_TIMEOUT_MS, killSignal: "SIGKILL" as const, maxBuffer: 1024 * 1024 };
58
+ if (position === "last") {
59
+ await execFileAsync("ffmpeg", ["-y", "-sseof", "-3", "-i", input, "-update", "1", "-q:v", "1", output], options);
60
+ return;
61
+ }
62
+ const sec = Number(position);
63
+ if (!Number.isFinite(sec) || sec < 0) throw new Error("position must be a non-negative number or 'last'");
64
+ if (sec > MAX_FRAME_POSITION_SECONDS) throw new Error("position exceeds the maximum supported seek time");
65
+ await execFileAsync("ffmpeg", ["-y", "-ss", String(sec), "-i", input, "-vframes", "1", output], options);
66
+ }
67
+
68
+ export async function extractGeneratedVideoFrameB64(generatedDir: string, filename: string, position = "last"): Promise<string> {
69
+ const inputPath = await safeGeneratedFilePath(generatedDir, filename, { requireMp4: true });
70
+ await assertLocalMp4(inputPath);
71
+ const tmpOut = join(generatedDir, `frame_tmp_${randomBytes(4).toString("hex")}.png`);
72
+ try {
73
+ await extractVideoFrame(inputPath, tmpOut, position);
74
+ return (await readFile(tmpOut)).toString("base64");
75
+ } finally {
76
+ await unlink(tmpOut).catch(() => {});
77
+ }
78
+ }