videobook 0.2.2 → 0.2.4

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 (61) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +10 -10
  3. package/dist/book-manager.js +40 -2
  4. package/dist/command-reference.js +16 -2
  5. package/dist/env-secrets.js +7 -4
  6. package/dist/image-edit-models.js +1 -0
  7. package/dist/jobs/complete-provider-task.js +7 -1
  8. package/dist/jobs/generate-media.js +4 -0
  9. package/dist/jobs/render-project.js +124 -31
  10. package/dist/media/adapters/index.js +4 -0
  11. package/dist/media/adapters/machgen.js +48 -0
  12. package/dist/media/adapters/types.js +1 -0
  13. package/dist/media/catalog.js +83 -0
  14. package/dist/media/complete.js +1 -1
  15. package/dist/media/cost.js +79 -6
  16. package/dist/media/frontier-gaps.js +49 -0
  17. package/dist/media/frontier.js +30 -0
  18. package/dist/media/json-schema.js +7 -0
  19. package/dist/media/listing.js +9 -2
  20. package/dist/media/machgen.js +261 -0
  21. package/dist/media/model-id.js +1 -1
  22. package/dist/media/preview.js +2 -0
  23. package/dist/media/prompt-dialects.js +2 -0
  24. package/dist/media/ref-fields.js +11 -3
  25. package/dist/media/request.js +49 -0
  26. package/dist/media/seedance-draft.js +48 -0
  27. package/dist/media/wire.js +3 -0
  28. package/dist/notebook-action-catalog.js +5 -0
  29. package/dist/one-password.js +13 -7
  30. package/dist/provider-status-link.js +1 -1
  31. package/dist/provider-task-status.js +6 -3
  32. package/dist/provider-usage.js +24 -10
  33. package/dist/queue.js +3 -0
  34. package/dist/tools/commands.js +1 -1
  35. package/dist/tools/estimate-cost.js +7 -5
  36. package/dist/tools/provider-key-status.js +4 -0
  37. package/dist/tools/reconcile-project-work.js +69 -1
  38. package/dist/usage-providers.js +4 -0
  39. package/dist/workspace-action-catalog.js +1 -1
  40. package/package.json +14 -13
  41. package/ui/assets/{alive-captions-C48o6dfv.js → alive-captions-Bp6dxosy.js} +1 -1
  42. package/ui/assets/confirm-bodies-Ds1HtaGc.js +1 -0
  43. package/ui/assets/{fullscreen-4m0qtZ7w.js → fullscreen-BCeVHjvY.js} +1 -1
  44. package/ui/assets/{goal-ui-2pUWP_B2.js → goal-ui-DbAGwgMN.js} +2 -2
  45. package/ui/assets/index-D1d-F1y9.js +37 -0
  46. package/ui/assets/index-DWfR79lA.css +1 -0
  47. package/ui/assets/{inspector-CnKySYc5.js → inspector-BJTC0luH.js} +1 -1
  48. package/ui/assets/{jobs-ui-lRoS1MbL.js → jobs-ui-C1cbw-xG.js} +1 -1
  49. package/ui/assets/{music-video-BQnnPp-X.js → music-video-CWtxPlxq.js} +1 -1
  50. package/ui/assets/ui-CXDi5Omq.js +2 -0
  51. package/ui/assets/vendor-BFupLz37.js +19 -0
  52. package/ui/assets/{vendor-flow-zBavaNEW.js → vendor-flow-CKiuzaeM.js} +1 -1
  53. package/ui/assets/{vendor-mcp-DQ7EusT9.js → vendor-mcp-DQEeVZH0.js} +1 -1
  54. package/ui/assets/videobook-J-j9bSjM.js +20 -0
  55. package/ui/index.html +12 -12
  56. package/ui/assets/confirm-bodies-aq6Az8Md.js +0 -1
  57. package/ui/assets/index-DmDH4gYr.js +0 -37
  58. package/ui/assets/index-DzPAnCUj.css +0 -1
  59. package/ui/assets/ui-OHFUngrc.js +0 -2
  60. package/ui/assets/vendor-yctIZ9Dz.js +0 -19
  61. package/ui/assets/videobook-DZ58pKW5.js +0 -20
package/CHANGELOG.md CHANGED
@@ -6,6 +6,65 @@ and the package uses [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.4] - 2026-10-23
10
+
11
+ ### Added
12
+
13
+ - Image and video model pickers show frontier rankings from the offline
14
+ `@apicity/cost` snapshot, with rank, Elo and price for the selected operation.
15
+ Other models follow in their existing order; unconfigured providers are
16
+ marked unavailable. Saved selections and default models stay unchanged.
17
+
18
+ - MachGen as a video provider, with six models: MiniMax H3, Seedance
19
+ 2.5, Seedance 2.0, Wan 3.0 and HappyHorse 1.1 for text, frame and
20
+ reference video, and Grok Imagine Video 1.5 for image-to-video. Each
21
+ model offers its own resolutions, durations and aspect ratios, and
22
+ cost estimates come from the `@apicity/cost` MachGen per-second rates.
23
+ Wan 3.0 always sends MachGen's documented adaptive, 30 fps, with-audio
24
+ config. Generations run as provider tasks: Videobook polls MachGen and
25
+ downloads the finished video with the API key.
26
+ - `MACHGEN_API_KEY` is a managed key. `videobook config`, 1Password
27
+ resolution and the provider keys panel include it. Without it, MachGen
28
+ generations stop with "MACHGEN_API_KEY environment variable is not set"
29
+ before an asset is created, and pending MachGen tasks wait unpolled.
30
+ - Seedance 2.5 Draft Complete (`kie:bytedance/seedance-2-5-draft`)
31
+ renders the 1080p final of a 480p Seedance 2.5 draft from the draft's
32
+ Kie task id. A draft rendered in the same book supplies the billed
33
+ seconds and reference video seconds for the estimate. The Kie Seedance
34
+ 2.5 model gains the matching `draft` option.
35
+
36
+ ### Changed
37
+
38
+ - Upgrade all application `@apicity/*` dependencies to 0.17.0 and add
39
+ `@apicity/machgen`.
40
+ - Kie Seedance 2.5 estimates use the 2026-10-22 rates, for example
41
+ $0.79 per second at 1080p without a reference video, up from $0.57.
42
+
43
+ ## [0.2.3] - 2026-10-21
44
+
45
+ ### Added
46
+
47
+ - Fal FLUX 3 Max text-to-video, image-to-video, and keyframes-to-video,
48
+ with generated audio and resolution-tiered per-second cost estimates.
49
+ - Bria Fibo 2 text-to-image with structured VGL prompts, and Fibo 2 Edit
50
+ for one to four reference images, including the image editor.
51
+ - H3 Max Reframe Video, which outpaints a source video to a target aspect
52
+ ratio at 480p, 768p, or 1080p while keeping its audio and timing.
53
+ Priced from fal's historical average because the request does not fix
54
+ the billed duration.
55
+
56
+ ### Changed
57
+
58
+ - Upgrade all application `@apicity/*` dependencies to 0.16.3.
59
+ - Windows x64 is supported. The release check runs the pack-and-install
60
+ smoke on Windows and blocks the release when it fails. Stopping
61
+ `videobook serve` from another process still ends it without a clean
62
+ shutdown, because Windows has no SIGTERM, and the next start replaces
63
+ the lock file.
64
+ - Dynamic fal requests are priced from fal's historical average when
65
+ `FAL_ADMIN_API_KEY` is set. Without the key, and for the two Seedance
66
+ formula gaps, the estimate stays at the local $0.
67
+
9
68
  ## [0.2.2] - 2026-10-21
10
69
 
11
70
  ### Added
package/README.md CHANGED
@@ -12,8 +12,8 @@ graph, and every change is recorded as a revision. This package installs one
12
12
  ## Requirements
13
13
 
14
14
  - Node.js 22 or newer.
15
- - macOS (Apple Silicon or Intel) or Linux (x64 or arm64). Windows is not
16
- supported yet; see the platform notes.
15
+ - macOS (Apple Silicon or Intel), Linux (x64 or arm64), or Windows x64.
16
+ See the platform notes.
17
17
  - About 800 MB of disk. Installing downloads the ffmpeg and yt-dlp binaries
18
18
  from GitHub, and the first video render downloads Chrome Headless Shell
19
19
  (about 150 MB) into `~/.cache/videobook/remotion`.
@@ -121,8 +121,9 @@ Caches live in `~/.cache/videobook`. Other settings: `PORT`,
121
121
  ## Provider keys
122
122
 
123
123
  The keys are `OPENAI_API_KEY`, `KIE_API_KEY`, `XAI_API_KEY`, `FAL_API_KEY`,
124
+ `FAL_ADMIN_API_KEY`,
124
125
  `DASHSCOPE_API_KEY`, `ELEVENLABS_API_KEY`, `KIMI_CODING_API_KEY`,
125
- `X_CLIENT_ID` and `X_CLIENT_SECRET`, `DROPBOX_CLIENT_ID` and
126
+ `MACHGEN_API_KEY`, `X_CLIENT_ID` and `X_CLIENT_SECRET`, `DROPBOX_CLIENT_ID` and
126
127
  `DROPBOX_CLIENT_SECRET`, and the `B2_*` storage settings. The server starts
127
128
  without any of them; tools that need one report which.
128
129
 
@@ -133,7 +134,8 @@ A key can come from four places. The first one that has it wins:
133
134
  Settings in the web UI.
134
135
  3. A 1Password secret reference in `.env`, such as
135
136
  `op://Private/FAL_API_KEY/password`.
136
- 4. A 1Password vault whose items are named after the keys.
137
+ 4. A 1Password vault: every item named like an environment variable fills
138
+ that variable, whether or not videobook lists it as a key.
137
139
 
138
140
  `videobook config` lists every key with its source and masks secret values.
139
141
  `config get`, `config set` and `config unset` work on one key and edit
@@ -177,12 +179,10 @@ come from 1Password are read-only in Settings.
177
179
  - macOS on Intel works, but ONNX Runtime has no Intel Mac build, so semantic
178
180
  search is unavailable there.
179
181
  - Linux x64 and arm64 are supported.
180
- - Windows is not supported yet. Every native dependency has a Windows x64
181
- build, but the release smoke test has not passed on Windows. Windows has no
182
- SIGTERM, so stopping `videobook serve` from another process ends it without
183
- a clean shutdown, and the next start replaces the lock file it leaves. The
184
- Release check workflow runs the smoke test on Windows for every release tag
185
- without blocking the release.
182
+ - Windows x64 is supported. Windows has no SIGTERM, so stopping
183
+ `videobook serve` from another process ends it without a clean shutdown,
184
+ and the next start replaces the lock file it leaves. The Release check
185
+ workflow runs the pack-and-install smoke on Windows for every release tag.
186
186
 
187
187
  ## Security
188
188
 
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
2
+ import { cp, lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { BOOK_SLUG_ERROR, normalizeBookSlug } from "videobook-engine/book-slug";
@@ -8,6 +8,43 @@ import { createEngine, err, isUuidV7, ok, } from "videobook-engine";
8
8
  function engineError(code, message) {
9
9
  return { code, message };
10
10
  }
11
+ const MOVE_ATTEMPTS = 8;
12
+ const RETRYABLE_MOVE_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
13
+ function systemCode(cause) {
14
+ if (!cause || typeof cause !== "object" || !("code" in cause))
15
+ return undefined;
16
+ const code = cause.code;
17
+ return typeof code === "string" ? code : undefined;
18
+ }
19
+ function delay(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+ // Windows denies renaming a directory while Defender, or a Dolt file that just
23
+ // closed, still holds a handle. Retry, then copy and drop the staging tree.
24
+ async function moveDirectory(source, destination) {
25
+ let last;
26
+ for (let attempt = 0; attempt < MOVE_ATTEMPTS; attempt += 1) {
27
+ try {
28
+ await rename(source, destination);
29
+ return;
30
+ }
31
+ catch (cause) {
32
+ last = cause;
33
+ if (!RETRYABLE_MOVE_CODES.has(systemCode(cause) ?? ""))
34
+ throw cause;
35
+ if (attempt < MOVE_ATTEMPTS - 1)
36
+ await delay(50 * 2 ** attempt);
37
+ }
38
+ }
39
+ try {
40
+ await cp(source, destination, { recursive: true });
41
+ }
42
+ catch {
43
+ await rm(destination, { recursive: true, force: true }).catch(() => undefined);
44
+ throw last;
45
+ }
46
+ await rm(source, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined);
47
+ }
11
48
  function isInside(parent, candidate) {
12
49
  const path = relative(parent, candidate);
13
50
  return path !== "" && !path.startsWith(`..${sep}`) && path !== "..";
@@ -114,8 +151,9 @@ export class BookManager {
114
151
  const created = temporaryEngine.book.get();
115
152
  temporaryEngine.close();
116
153
  temporaryEngine = null;
154
+ await delay(0);
117
155
  const destination = this.bookRoot(created.bookId);
118
- await rename(temporaryRoot, destination);
156
+ await moveDirectory(temporaryRoot, destination);
119
157
  finalRoot = destination;
120
158
  const engine = this.createEngine(finalRoot);
121
159
  finalEngine = engine;
@@ -1,4 +1,6 @@
1
- import { listModels } from "./media/listing.js";
1
+ import { FRONTIER } from "@apicity/cost";
2
+ import { FRONTIER_CATALOG_GAPS } from "./media/frontier-gaps.js";
3
+ import { listModels, modelIsHidden } from "./media/listing.js";
2
4
  import { buildWorkspaceActionCatalog } from "./workspace-action-catalog.js";
3
5
  import { WORKSPACE_ACTION_SURFACES } from "./workspace-action-surfaces.js";
4
6
  function oneLine(value) {
@@ -80,12 +82,24 @@ export function renderGenerationModels() {
80
82
  const rows = models.map((model) => {
81
83
  const refs = model.slots.map((slot) => `${slot.role} ${slot.min}-${slot.max ?? "n"}`).join(", ") || "none";
82
84
  const modes = model.modes.length > 1 ? model.modes.map((mode) => mode.id).join(", ") : "";
83
- return `| \`${escapeTable(model.id)}\` | ${escapeTable(model.title)} | ${model.media} | ${model.entry.operations.join(", ")} | ${escapeTable(refs)} | ${modes} | ${model.entry.hidden ? "hidden" : "visible"} |`;
85
+ return `| \`${escapeTable(model.id)}\` | ${escapeTable(model.title)} | ${model.media} | ${model.entry.operations.join(", ")} | ${escapeTable(refs)} | ${modes} | ${modelIsHidden(model.entry) ? "hidden" : "visible"} |`;
84
86
  });
87
+ const gaps = Object.entries(FRONTIER_CATALOG_GAPS).map(([endpoint, reason]) => `| ${escapeTable(endpoint)} | ${escapeTable(reason)} |`).join("\n");
88
+ const unmapped = [...new Set(Object.values(FRONTIER).flatMap((snapshot) => snapshot.entries.filter((entry) => entry.endpoints.length === 0).map((entry) => entry.model)))];
85
89
  return `# Generation Models
86
90
 
87
91
  Generated by \`npm run docs:commands\` from the media catalog (\`server/src/media/catalog.ts\`). Call \`generate_image\`, \`generate_video\` or \`generate_audio\` with one of these model ids; \`list_generation_models\` returns the same list with every option, enum, range and default read from the apicity schemas.
88
92
 
93
+ Image and video pickers rank exact catalog endpoints using the committed top-10 snapshots in @apicity/cost 0.17.0, originally sourced from Artificial Analysis. No leaderboard is fetched at runtime. Rankings are per operation: t2i, i2i, t2v, i2v, v2v, and r2v (derived from the text/image-to-video boards). Each ranked option shows rank, Elo, and a bundled price label; prices are indicative ranges, not a quote for the selected settings.
94
+
95
+ Configured frontier models appear first; other models retain their existing order in a separate group. Providers without credentials are marked unavailable. Saved selections and defaults stay unchanged. Catalog variants on a frontier board are included even if previously hidden; non-frontier hidden variants remain hidden. A model version or lite variant never inherits another endpoint's score.
96
+
97
+ Top-10 coverage policy: every registry endpoint must map exactly to a catalog entry or appear in the known gaps below. Models with no provider endpoint in the registry: ${unmapped.join(", ")}. Catalog gaps do not imply provider API support is missing; adding those endpoints is separate work.
98
+
99
+ | Known endpoint gap | Reason |
100
+ | --- | --- |
101
+ ${gaps}
102
+
89
103
  - Models: ${models.length}
90
104
 
91
105
  | Model | Title | Media | Operations | Refs | Modes | Picker |
@@ -6,9 +6,10 @@
6
6
  * them into the environment BEFORE Node starts (npm run dev:server).
7
7
  * 2. A plain `.env` file, loaded here with dotenv semantics: never override
8
8
  * a var that is already set, so op-injected values always win.
9
- * 3. op:// references left in the environment or `.env`, plus the vault
10
- * named by VIDEOBOOK_OP_VAULT, resolved through the 1Password CLI by
11
- * resolveOnePassword() before a server starts.
9
+ * 3. op:// references left in the environment or `.env`, plus every
10
+ * env-named item in the vault named by VIDEOBOOK_OP_VAULT, resolved
11
+ * through the 1Password CLI by resolveOnePassword() before a server
12
+ * starts.
12
13
  *
13
14
  * A managed key that was already set before `.env` loaded, or that 1Password
14
15
  * resolved, is therefore "1password" — externally managed and read-only,
@@ -35,8 +36,10 @@ export const MANAGED_ENV_KEYS = [
35
36
  "DROPBOX_CLIENT_SECRET",
36
37
  "KIMI_CODING_API_KEY",
37
38
  "FAL_API_KEY",
39
+ "FAL_ADMIN_API_KEY",
38
40
  "DASHSCOPE_API_KEY",
39
41
  "ELEVENLABS_API_KEY",
42
+ "MACHGEN_API_KEY",
40
43
  "B2_ACCESS_KEY_ID",
41
44
  "B2_SECRET_ACCESS_KEY",
42
45
  "B2_REGION",
@@ -124,7 +127,7 @@ export function createSecretStore(options = {}) {
124
127
  if (resolution)
125
128
  return resolution;
126
129
  loadDotenv();
127
- const resolved = resolveOnePasswordEnv(processEnv, MANAGED_ENV_KEYS);
130
+ const resolved = resolveOnePasswordEnv(processEnv);
128
131
  for (const key of [...resolved.fromReferences, ...resolved.fromVault])
129
132
  fromOnePassword.add(key);
130
133
  resolution = resolved;
@@ -18,6 +18,7 @@ const EDIT_MODELS = [
18
18
  { catalogModel: "fal:fal-ai/nano-banana-2/edit", label: "Nano Banana 2" },
19
19
  { catalogModel: "fal:alibaba/qwen-image-3/edit", label: "Qwen 3.0 Edit" },
20
20
  { catalogModel: "fal:fal-ai/wan/v2.7/edit", label: "Wan 2.7 Edit" },
21
+ { catalogModel: "fal:bria/fibo-2/edit", label: "Fibo 2 Edit" },
21
22
  ];
22
23
  function imageSlotMax(model) {
23
24
  return model.slots.find((slot) => slot.role === "image")?.max ?? null;
@@ -6,6 +6,7 @@ import { registerJobHandler } from "../queue.js";
6
6
  import { runFfmpeg } from "../ffmpeg.js";
7
7
  import { completeMediaGeneration, providerResultUrls } from "../media/complete.js";
8
8
  import { MEDIA_TASK_TYPES } from "../media/adapters/types.js";
9
+ import { downloadMachGenVideo } from "../media/adapters/machgen.js";
9
10
  import { legacyMediaForTaskType, legacyMediaMeta } from "../media/legacy-task-types.js";
10
11
  const LOCK_DURATION_MS = 300_000;
11
12
  const TASK_TYPES_BY_COMPLETION_FAMILY = {
@@ -109,7 +110,7 @@ export function registerCompleteProviderTaskJob(engine) {
109
110
  assetDir,
110
111
  bookId,
111
112
  meta: legacyMediaMeta(taskType, meta),
112
- outputs: urls.map((url) => ({ url })),
113
+ outputs: await mediaOutputs(taskType, taskId, urls),
113
114
  });
114
115
  break;
115
116
  default: {
@@ -126,6 +127,11 @@ export function registerCompleteProviderTaskJob(engine) {
126
127
  }
127
128
  });
128
129
  }
130
+ async function mediaOutputs(taskType, taskId, urls) {
131
+ if (taskType === MEDIA_TASK_TYPES.machgen)
132
+ return [await downloadMachGenVideo(taskId, urls[0])];
133
+ return urls.map((url) => ({ url }));
134
+ }
129
135
  async function completeIsolateVocals(engine, assetId, meta, resultUrls) {
130
136
  const vocalsUrl = resultUrls[0];
131
137
  if (!vocalsUrl)
@@ -7,6 +7,7 @@ import { resolveModel } from "../media/descriptor.js";
7
7
  import { buildWireRequest } from "../media/request.js";
8
8
  import { applyPromptDialect } from "../media/prompt-dialects.js";
9
9
  import { checkSeedanceEditSource, checkInsertVideoTiming, checkRefDurations, resolveRefInput, uploadRefs } from "../media/refs.js";
10
+ import { seedanceDraftCostHints } from "../media/seedance-draft.js";
10
11
  import { adapterFor } from "../media/adapters/index.js";
11
12
  import { completeMediaGeneration } from "../media/complete.js";
12
13
  function storedPayload(model, request) {
@@ -19,6 +20,9 @@ async function replaceAudioMeta(engine, ref) {
19
20
  return { replaceAudioWith: { assetId: input.assetId, filename: input.filename } };
20
21
  }
21
22
  export async function generationSourceCostHints(engine, refs, job) {
23
+ const draftHints = await seedanceDraftCostHints(engine, job.model, job.options ?? {});
24
+ if (Object.keys(draftHints).length > 0)
25
+ return draftHints;
22
26
  const videoSeconds = await referenceVideoDuration(engine, refs.sourceVideoIds);
23
27
  if (videoSeconds !== undefined) {
24
28
  return { inputDurationSeconds: videoSeconds, ...(isSeedanceEdit(job.model, job.mode) ? { durationSeconds: videoSeconds } : {}) };
@@ -24,7 +24,10 @@ import { isKeywordPlacementCacheValid, keywordPlacementFingerprint, readKeywordP
24
24
  import { resolveKeywordPlacements } from "../remotion/keyword-placement.js";
25
25
  import { aliveRenderTrack } from "../alive-captions/track.js";
26
26
  const LOCK_DURATION_MS = 1_800_000; // 30 min for renders
27
- const CLAIM_WAIT_MS = 30_000;
27
+ // All three orientations lock the same source workspace. A subtitle render
28
+ // of one orientation outlives the old 30s wait, so the others failed and the
29
+ // queue then marked the playable source as media-failed.
30
+ const CLAIM_WAIT_MS = 15 * 60_000;
28
31
  // Lazy subtitle-language resolution: serve the cached translated sidecar when
29
32
  // it matches the current transcript, translate-and-cache when it doesn't, and
30
33
  // fall back to the original-language transcript on any translation failure —
@@ -62,6 +65,76 @@ async function resolveSubtitleSegments(engine, assetId, raw, subtitleLanguage, s
62
65
  return sourceSegments;
63
66
  }
64
67
  }
68
+ function preserveArtifactState(error) {
69
+ const preserved = error instanceof Error ? error : new Error(String(error));
70
+ Object.assign(preserved, { preserveArtifactState: true });
71
+ return preserved;
72
+ }
73
+ async function sourceVideoIsPlayable(engine, assetId) {
74
+ const manifest = await engine.files.manifest(assetId);
75
+ if (!manifest.ok)
76
+ return false;
77
+ return findVideoFile(manifest.value.files) !== null;
78
+ }
79
+ async function clearRenderError(engine, assetId) {
80
+ const cleared = await engine.jobs.failures.clear(assetId);
81
+ if (cleared.ok && cleared.value)
82
+ return;
83
+ // A reaped render lease marks the view failed without writing a
84
+ // generation-error row, and clear() then no-ops. Write a row so the same
85
+ // clear can restore the playable source.
86
+ const view = engine.jobs.artifactWork.read(assetId);
87
+ if (!view.ok || view.value?.status !== "error")
88
+ return;
89
+ const written = await engine.jobs.failures.write(assetId, {
90
+ message: "Retrying subtitle render",
91
+ });
92
+ if (!written.ok)
93
+ return;
94
+ await engine.jobs.failures.clear(assetId);
95
+ }
96
+ /** A failed subtitle render must not leave a playable source stuck in error. */
97
+ export async function clearRenderErrorIfPlayable(engine, assetId) {
98
+ if (!(await sourceVideoIsPlayable(engine, assetId)))
99
+ return false;
100
+ const view = engine.jobs.artifactWork.read(assetId);
101
+ if (view.ok && view.value?.status === "error") {
102
+ await clearRenderError(engine, assetId);
103
+ }
104
+ return true;
105
+ }
106
+ /** Drop a queued render view when this attempt never took the lease. */
107
+ export async function releaseQueuedRenderIfPlayable(engine, assetId) {
108
+ if (!(await sourceVideoIsPlayable(engine, assetId)))
109
+ return;
110
+ const view = engine.jobs.artifactWork.read(assetId);
111
+ if (!view.ok || view.value?.status !== "pending")
112
+ return;
113
+ const owner = engine.jobs.artifactWork.begin(assetId, {
114
+ kind: "render",
115
+ ownerKind: "job",
116
+ durationMs: 5_000,
117
+ });
118
+ if (!owner)
119
+ return;
120
+ engine.jobs.artifactWork.complete(assetId, owner.ownerId);
121
+ // complete() does not remove a generation-error row, and that row alone
122
+ // still reports the playable source as failed.
123
+ await clearRenderError(engine, assetId);
124
+ }
125
+ export async function preservePlayableRenderSources(engine, assetIds, markAssetFailureHandled, error) {
126
+ const playable = await Promise.all(assetIds.map(async (assetId) => {
127
+ const kept = await clearRenderErrorIfPlayable(engine, assetId);
128
+ if (kept)
129
+ await releaseQueuedRenderIfPlayable(engine, assetId);
130
+ return kept;
131
+ }));
132
+ if (playable.some(Boolean)) {
133
+ markAssetFailureHandled();
134
+ throw preserveArtifactState(error);
135
+ }
136
+ throw error instanceof Error ? error : new Error(String(error));
137
+ }
65
138
  function abortRace(signal) {
66
139
  return new Promise((_, reject) => {
67
140
  const fail = () => {
@@ -76,13 +149,39 @@ function abortRace(signal) {
76
149
  }
77
150
  export function registerRenderProjectJob(engine) {
78
151
  registerJobHandler("render_project", async (payload, ctx) => {
79
- ctx.signal.throwIfAborted();
80
152
  const { orientation, assets, subtitlesEnabled, subtitleLanguage = "original", highlightStyle, position, fontSize, textColor, highlightColor, audioFadeIn, audioFadeOut, audioVolume, osgImagePath: payloadOsgImagePath, osgAssetId, osgPosition, osgSize, osgScale, osgOpacity, osgStartSeconds, osgEndSeconds, osgTailSeconds, keywordGraphicsEnabled = false, keywordGraphicsMaxCount = DEFAULT_KEYWORD_GRAPHICS_MAX_COUNT, keywordGraphicsLingerSeconds = DEFAULT_KEYWORD_GRAPHICS_LINGER_SECONDS, keywordGraphicsKeywords = null, backgroundMode, strokeWidth, strokeColor, shadowBlur, shadowOpacity, } = payload;
81
- const bundleDir = await Promise.race([
82
- getSharedBundleLocation(),
83
- abortRace(ctx.signal),
84
- ]);
85
- ctx.signal.throwIfAborted();
153
+ let bundleDir;
154
+ let osgImagePath = payloadOsgImagePath;
155
+ let osgCopyName = null;
156
+ let osgCopyPath = null;
157
+ try {
158
+ ctx.signal.throwIfAborted();
159
+ bundleDir = await Promise.race([
160
+ getSharedBundleLocation(),
161
+ abortRace(ctx.signal),
162
+ ]);
163
+ ctx.signal.throwIfAborted();
164
+ if (!osgImagePath && osgAssetId) {
165
+ const resolved = await resolveOsgImagePath(engine, osgAssetId);
166
+ if (resolved.ok) {
167
+ osgImagePath = resolved.path;
168
+ }
169
+ else {
170
+ logger.warn("overlay-skipped", { assetId: osgAssetId, error: resolved.error });
171
+ }
172
+ }
173
+ if (osgImagePath) {
174
+ osgCopyName = `osg-project-${Date.now()}${basename(osgImagePath).replace(/^[^.]*/, "")}`;
175
+ osgCopyPath = join(bundleDir, osgCopyName);
176
+ await copyFile(resolve(osgImagePath), osgCopyPath);
177
+ }
178
+ }
179
+ catch (error) {
180
+ const assetIds = Array.isArray(assets)
181
+ ? assets.flatMap((asset) => (asset && typeof asset.assetId === "string" ? [asset.assetId] : []))
182
+ : [];
183
+ return preservePlayableRenderSources(engine, assetIds, () => ctx.markAssetFailureHandled(), error);
184
+ }
86
185
  const targetWidth = orientation === "portrait" ? PORTRAIT_WIDTH : orientation === "square" ? SQUARE_WIDTH : LANDSCAPE_WIDTH;
87
186
  const targetHeight = orientation === "portrait" ? PORTRAIT_HEIGHT : orientation === "square" ? SQUARE_HEIGHT : LANDSCAPE_HEIGHT;
88
187
  const outputFilename = orientation === "portrait"
@@ -90,36 +189,18 @@ export function registerRenderProjectJob(engine) {
90
189
  : orientation === "square"
91
190
  ? "square.mp4"
92
191
  : "landscape.mp4";
93
- // The stored-settings path (set_video_overlay → lazy render) carries only
94
- // osgAssetId; resolve it to a file at run time so the overlay actually
95
- // renders — and so the path survives file moves between enqueue and run.
96
- let osgImagePath = payloadOsgImagePath;
97
- if (!osgImagePath && osgAssetId) {
98
- const resolved = await resolveOsgImagePath(engine, osgAssetId);
99
- if (resolved.ok) {
100
- osgImagePath = resolved.path;
101
- }
102
- else {
103
- logger.warn("overlay-skipped", { assetId: osgAssetId, error: resolved.error });
104
- }
105
- }
106
- // Copy OSG image into bundle dir once if provided
107
- let osgCopyName = null;
108
- let osgCopyPath = null;
109
- if (osgImagePath) {
110
- osgCopyName = `osg-project-${Date.now()}${basename(osgImagePath).replace(/^[^.]*/, "")}`;
111
- osgCopyPath = join(bundleDir, osgCopyName);
112
- await copyFile(resolve(osgImagePath), osgCopyPath);
113
- }
114
192
  const rendered = [];
115
193
  const failed = [];
116
194
  try {
117
195
  for (const asset of assets) {
196
+ const playable = await clearRenderErrorIfPlayable(engine, asset.assetId);
118
197
  try {
119
198
  ctx.signal.throwIfAborted();
120
199
  const claim = await claimLockedLease(engine, asset.assetId, asset.assetDir, "render", { durationMs: LOCK_DURATION_MS, data: { state: `rendering-${orientation}` } }, { durationMs: LOCK_DURATION_MS, waitForActiveMs: CLAIM_WAIT_MS, meta: { orientation } });
121
200
  if (!claim.ok) {
122
201
  failed.push({ assetId: asset.assetId, error: claim.reason });
202
+ if (playable)
203
+ await releaseQueuedRenderIfPlayable(engine, asset.assetId);
123
204
  continue;
124
205
  }
125
206
  const lease = { ownerId: claim.ownerId };
@@ -350,8 +431,9 @@ export function registerRenderProjectJob(engine) {
350
431
  }
351
432
  finally {
352
433
  clearInterval(heartbeat);
353
- if (renderSucceeded) {
434
+ if (renderSucceeded || playable) {
354
435
  await engine.jobs.artifactWork.complete(asset.assetId, lease.ownerId);
436
+ await clearRenderError(engine, asset.assetId);
355
437
  }
356
438
  else {
357
439
  await engine.jobs.artifactWork.fail(asset.assetId, lease.ownerId, {
@@ -362,8 +444,13 @@ export function registerRenderProjectJob(engine) {
362
444
  }
363
445
  }
364
446
  catch (err) {
365
- if (ctx.signal.aborted)
447
+ if (ctx.signal.aborted) {
448
+ if (playable) {
449
+ ctx.markAssetFailureHandled();
450
+ throw preserveArtifactState(err);
451
+ }
366
452
  throw err;
453
+ }
367
454
  failed.push({ assetId: asset.assetId, error: err instanceof Error ? err.message : String(err) });
368
455
  }
369
456
  }
@@ -373,7 +460,13 @@ export function registerRenderProjectJob(engine) {
373
460
  await unlink(osgCopyPath).catch(() => { });
374
461
  }
375
462
  if (rendered.length === 0 && failed.length > 0) {
376
- throw new Error(`Render failed: ${failed.map((f) => `${f.assetId}: ${f.error}`).join("; ")}`);
463
+ const error = new Error(`Render failed: ${failed.map((f) => `${f.assetId}: ${f.error}`).join("; ")}`);
464
+ const playable = await Promise.all(failed.map((entry) => clearRenderErrorIfPlayable(engine, entry.assetId)));
465
+ if (playable.some(Boolean)) {
466
+ ctx.markAssetFailureHandled();
467
+ throw preserveArtifactState(error);
468
+ }
469
+ throw error;
377
470
  }
378
471
  return { success: failed.length === 0, rendered, failed };
379
472
  });
@@ -2,6 +2,7 @@ import { alibabaAdapterFor } from "./alibaba.js";
2
2
  import { elevenLabsAdapter } from "./elevenlabs.js";
3
3
  import { falAdapter } from "./fal.js";
4
4
  import { kieAdapter } from "./kie.js";
5
+ import { machgenAdapter } from "./machgen.js";
5
6
  import { xaiAdapter } from "./xai.js";
6
7
  export function adapterFor(model) {
7
8
  switch (model.provider) {
@@ -15,6 +16,8 @@ export function adapterFor(model) {
15
16
  return alibabaAdapterFor(model.apiId);
16
17
  case "elevenlabs":
17
18
  return elevenLabsAdapter;
19
+ case "machgen":
20
+ return machgenAdapter;
18
21
  }
19
22
  }
20
23
  export const PROVIDER_ENV_KEYS = {
@@ -23,4 +26,5 @@ export const PROVIDER_ENV_KEYS = {
23
26
  xai: "XAI_API_KEY",
24
27
  alibaba: "DASHSCOPE_API_KEY",
25
28
  elevenlabs: "ELEVENLABS_API_KEY",
29
+ machgen: "MACHGEN_API_KEY",
26
30
  };
@@ -0,0 +1,48 @@
1
+ import { createMachGen } from "../../usage-providers.js";
2
+ import { requireApiKey } from "../../api-key.js";
3
+ import { uploadAssetFile } from "../../upload.js";
4
+ import { providerEndpoints } from "../endpoints.js";
5
+ import { nonEmpty, record, writePendingTask } from "./pending.js";
6
+ import { MEDIA_TASK_TYPES } from "./types.js";
7
+ const ASSET_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
8
+ export function machgenProvider(timeout = 60_000, baseURL = providerEndpoints().machgenBaseURL) {
9
+ return createMachGen({
10
+ apiKey: requireApiKey("MACHGEN_API_KEY"),
11
+ timeout,
12
+ ...(baseURL ? { baseURL } : {}),
13
+ });
14
+ }
15
+ function costHints(request, hints) {
16
+ const seconds = record(request.video_config)?.duration_secs;
17
+ return {
18
+ ...(typeof seconds === "number" && seconds > 0 ? { durationSeconds: seconds } : {}),
19
+ ...hints,
20
+ };
21
+ }
22
+ async function submit(request, context) {
23
+ const hints = costHints(request, context.costHints);
24
+ if (Object.keys(hints).length > 0) {
25
+ const written = await context.engine.metadata.artifacts.write(context.assetId, "generation-cost-hints", hints);
26
+ if (!written.ok)
27
+ throw new Error(written.error.message);
28
+ }
29
+ const response = await machgenProvider().api.v0.generate(request, context.ctx.signal);
30
+ const taskId = nonEmpty(response.task_id);
31
+ if (!taskId)
32
+ throw new Error("MachGen accepted the request without a task id");
33
+ await writePendingTask(context, taskId, MEDIA_TASK_TYPES.machgen, { machgenModel: context.model.apiId });
34
+ return { kind: "pending", taskId };
35
+ }
36
+ export async function downloadMachGenVideo(taskId, url) {
37
+ const bytes = Buffer.from(await machgenProvider(ASSET_DOWNLOAD_TIMEOUT_MS).api.v0.assets(taskId));
38
+ if (bytes.length === 0)
39
+ throw new Error(`MachGen task ${taskId} returned an empty video`);
40
+ return { bytes, extension: "mp4", ...(url ? { url } : {}) };
41
+ }
42
+ export const machgenAdapter = {
43
+ provider: "machgen",
44
+ envKey: "MACHGEN_API_KEY",
45
+ upload: uploadAssetFile,
46
+ uploadUrl: async (url) => url,
47
+ submit,
48
+ };
@@ -2,4 +2,5 @@ export const MEDIA_TASK_TYPES = {
2
2
  kie: "media_kie",
3
3
  fal: "fal_media",
4
4
  xai: "xai_media",
5
+ machgen: "machgen_media",
5
6
  };