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
@@ -1,4 +1,4 @@
1
- import { arrayBounds, arrayItems, isArraySchema, isUrlObject, propertyAt } from "./json-schema.js";
1
+ import { arrayBounds, arrayItems, isArraySchema, isKeyframeItem, isUrlObject, propertyAt } from "./json-schema.js";
2
2
  const CANDIDATES = {
3
3
  first_frame: [
4
4
  "first_frame_url",
@@ -55,12 +55,14 @@ function expandField(input, field) {
55
55
  const required = isRequired(input, field);
56
56
  if (isArraySchema(schema)) {
57
57
  const bounds = arrayBounds(schema);
58
+ const items = arrayItems(schema);
58
59
  return [{
59
60
  path: field,
60
61
  multi: true,
61
62
  max: bounds.max,
62
63
  min: required ? Math.max(1, bounds.min) : 0,
63
- urlObject: isUrlObject(arrayItems(schema)),
64
+ urlObject: isUrlObject(items),
65
+ ...(isKeyframeItem(items) ? { keyframe: true } : {}),
64
66
  }];
65
67
  }
66
68
  return [{ path: field, multi: false, max: 1, min: required ? 1 : 0, urlObject: isUrlObject(schema) }];
@@ -83,7 +85,13 @@ export function resolveSlot(role, input, entry) {
83
85
  throw new Error(`${entry.id}: ref field ${name} for ${role} is not in the apicity schema`);
84
86
  }
85
87
  const expanded = names.flatMap((name) => expandField(input, name));
86
- const fields = expanded.map(({ path, multi, max, urlObject }) => ({ path, multi, max, urlObject }));
88
+ const fields = expanded.map(({ path, multi, max, urlObject, keyframe }) => ({
89
+ path,
90
+ multi,
91
+ max,
92
+ urlObject,
93
+ ...(keyframe ? { keyframe: true } : {}),
94
+ }));
87
95
  const max = expanded.some((field) => field.max === null)
88
96
  ? null
89
97
  : expanded.reduce((sum, field) => sum + (field.max ?? 0), 0);
@@ -12,6 +12,15 @@ function childContainer(current, key, nextIsIndex) {
12
12
  holder[key] = created;
13
13
  return created;
14
14
  }
15
+ function getPath(target, path) {
16
+ let current = target;
17
+ for (const segment of path.split(".")) {
18
+ if (current === null || typeof current !== "object")
19
+ return undefined;
20
+ current = current[segment];
21
+ }
22
+ return current;
23
+ }
15
24
  function setPath(target, path, value) {
16
25
  const segments = path.split(".");
17
26
  let current = target;
@@ -67,12 +76,51 @@ function checkRefs(model, refs, mode) {
67
76
  function refValue(field, url, item) {
68
77
  return field.urlObject ? { ...item, url } : url;
69
78
  }
79
+ function keyframeIndexes(count, lastFrame) {
80
+ if (count <= 1)
81
+ return [0];
82
+ return Array.from({ length: count }, (_, index) => Math.round((index * lastFrame) / (count - 1)));
83
+ }
84
+ function retimeKeyframeField(model, value, field) {
85
+ const fps = model.entry.keyframeFps;
86
+ if (!fps)
87
+ return;
88
+ const current = getPath(value, field.path);
89
+ if (!Array.isArray(current) || current.length === 0)
90
+ return;
91
+ const duration = typeof value.duration === "number" && Number.isFinite(value.duration) && value.duration > 0
92
+ ? value.duration
93
+ : 5;
94
+ const indexes = keyframeIndexes(current.length, Math.round(duration * fps));
95
+ setPath(value, field.path, current.map((frame, index) => {
96
+ const imageUrl = typeof frame === "object" && frame !== null && "image_url" in frame
97
+ ? frame.image_url
98
+ : frame;
99
+ return { image_url: imageUrl, frame_index: indexes[index] };
100
+ }));
101
+ }
102
+ function retimeKeyframes(model, value) {
103
+ if (!model.entry.keyframeFps)
104
+ return;
105
+ for (const slot of model.slots) {
106
+ for (const field of slot.fields) {
107
+ if (field.keyframe)
108
+ retimeKeyframeField(model, value, field);
109
+ }
110
+ }
111
+ }
70
112
  function placeSlot(model, slot, value, urls) {
71
113
  const item = model.entry.refItem?.[slot.role];
72
114
  let cursor = 0;
73
115
  for (const field of slot.fields) {
74
116
  if (cursor >= urls.length)
75
117
  return;
118
+ if (field.keyframe) {
119
+ const taken = field.max === null ? urls.slice(cursor) : urls.slice(cursor, cursor + field.max);
120
+ setPath(value, field.path, taken.map((url) => ({ image_url: url, frame_index: 0 })));
121
+ cursor += taken.length;
122
+ continue;
123
+ }
76
124
  if (!field.multi) {
77
125
  setPath(value, field.path, refValue(field, urls[cursor], item));
78
126
  cursor += 1;
@@ -175,6 +223,7 @@ export function buildWireRequest(model, call) {
175
223
  if (optionError)
176
224
  return { ok: false, error: optionError };
177
225
  applyFrameOptions(model, value, refs, call.mode);
226
+ retimeKeyframes(model, value);
178
227
  if (isSeedanceEdit(model.id, call.mode)) {
179
228
  value.aspect_ratio = "adaptive";
180
229
  value.duration = -1;
@@ -0,0 +1,48 @@
1
+ const SEEDANCE_DRAFT_COMPLETE_MODEL = "kie:bytedance/seedance-2-5-draft";
2
+ const SEEDANCE_DRAFT_SOURCE_MODEL = "bytedance/seedance-2-5";
3
+ function record(value) {
4
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
5
+ }
6
+ function seconds(value) {
7
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
8
+ }
9
+ async function readRecord(engine, artifactId, key) {
10
+ const result = await engine.metadata.artifacts.read(artifactId, key);
11
+ return result.ok ? record(result.value) : {};
12
+ }
13
+ async function draftArtifact(engine, taskId) {
14
+ for (const artifact of engine.artifacts.list()) {
15
+ if (artifact.kind !== "video")
16
+ continue;
17
+ const stored = await readRecord(engine, artifact.artifactId, "kie-task-id");
18
+ if (stored.taskId === taskId)
19
+ return artifact.artifactId;
20
+ }
21
+ return null;
22
+ }
23
+ function inputSeconds(input, hints) {
24
+ const stored = hints.inputDurationSeconds;
25
+ if (typeof stored === "number" && Number.isFinite(stored) && stored >= 0)
26
+ return stored;
27
+ const videos = input.reference_video_urls;
28
+ return Array.isArray(videos) && videos.length > 0 ? undefined : 0;
29
+ }
30
+ export async function seedanceDraftCostHints(engine, model, options) {
31
+ if (model !== SEEDANCE_DRAFT_COMPLETE_MODEL)
32
+ return {};
33
+ const taskId = typeof options.draft_task_id === "string" ? options.draft_task_id.trim() : "";
34
+ const artifactId = taskId ? await draftArtifact(engine, taskId) : null;
35
+ if (!artifactId)
36
+ return {};
37
+ const payload = await readRecord(engine, artifactId, "generation-payload");
38
+ const input = record(payload.input);
39
+ if (payload.model !== SEEDANCE_DRAFT_SOURCE_MODEL || input.draft !== true)
40
+ return {};
41
+ const generated = seconds((await readRecord(engine, artifactId, "original")).duration);
42
+ const durationSeconds = seconds(input.duration) ?? (generated === undefined ? undefined : Math.round(generated));
43
+ const inputDurationSeconds = inputSeconds(input, await readRecord(engine, artifactId, "generation-cost-hints"));
44
+ return {
45
+ ...(durationSeconds !== undefined ? { durationSeconds } : {}),
46
+ ...(inputDurationSeconds !== undefined ? { inputDurationSeconds } : {}),
47
+ };
48
+ }
@@ -4,6 +4,7 @@ import { FAL_ENDPOINT_REQUEST_SCHEMAS } from "@apicity/fal/zod";
4
4
  import { XaiVideoGenerateRequestSchema } from "@apicity/xai/zod";
5
5
  import { AlibabaMultimodalGenerationParametersSchema, AlibabaQwenImageEditStableModelSchema, AlibabaQwenImageGenerationStableModelSchema, } from "@apicity/alibaba/zod";
6
6
  import { ElevenLabsComposeMusicRequestSchema, ElevenLabsSoundGenerationRequestSchema, ElevenLabsTextToSpeechRequestSchema, ElevenLabsVideoToMusicRequestSchema, } from "@apicity/elevenlabs/zod";
7
+ import { machgenWire } from "./machgen.js";
7
8
  const KIE_VEO_MODELS = new Set(["veo3", "veo3_fast", "veo3_lite"]);
8
9
  const SUNO_CALLBACK_URL = "https://localhost/videobook/suno-callback";
9
10
  function objectShape(schema) {
@@ -157,5 +158,7 @@ export function wireSpec(parsed) {
157
158
  return alibabaWire(parsed.apiId);
158
159
  case "elevenlabs":
159
160
  return elevenLabsWire(parsed.apiId);
161
+ case "machgen":
162
+ return machgenWire(parsed.apiId);
160
163
  }
161
164
  }
@@ -1,6 +1,8 @@
1
1
  import { LIGHTNING_SCENE_MODEL, LIGHTNING_VIDEO_MODEL } from "./lightning-defaults.js";
2
2
  import { notebookCellTableRows } from "videobook-engine";
3
3
  import { notebookGridTag } from "videobook-engine/notebook-grid";
4
+ import { modelFrontierRankings } from "./media/frontier.js";
5
+ import { PROVIDER_ENV_KEYS } from "./media/adapters/index.js";
4
6
  import { listModels } from "./media/listing.js";
5
7
  import { notebookGenerationInputContract } from "./notebook-generation-contract.js";
6
8
  import { controlsForModel } from "./notebook-generation-controls.js";
@@ -265,6 +267,7 @@ const providerLabels = {
265
267
  xai: "xAI",
266
268
  alibaba: "Alibaba",
267
269
  elevenlabs: "ElevenLabs",
270
+ machgen: "MachGen",
268
271
  };
269
272
  function hasDualFrame(model) {
270
273
  const roles = new Set(model.slots.map((slot) => slot.role));
@@ -297,6 +300,8 @@ function generationAction(model) {
297
300
  ...(DEFAULT_MODELS.has(model.id) ? { defaultPriority: 0, isDefault: true } : {}),
298
301
  inputContract: notebookGenerationInputContract(model),
299
302
  controls: controlsForModel(model),
303
+ available: Boolean(process.env[PROVIDER_ENV_KEYS[model.provider]]?.trim()),
304
+ frontier: modelFrontierRankings(model.id),
300
305
  };
301
306
  }
302
307
  export function listNotebookActions(options = {}) {
@@ -35,14 +35,19 @@ export function readOnePasswordSettings(env) {
35
35
  export function redactToken(text, token) {
36
36
  return token ? text.split(token).join("***") : text;
37
37
  }
38
- export function planReferences(env, managedKeys, vault, vaultTitles) {
38
+ /** Every vault item titled like an environment variable fills that variable, except the vault settings themselves. */
39
+ function isVaultEnvTitle(title) {
40
+ return ENV_NAME.test(title) && title !== OP_VAULT_SETTING && title !== OP_TOKEN_SETTING;
41
+ }
42
+ export function planReferences(env, vault, vaultTitles) {
39
43
  const explicit = Object.entries(env)
40
44
  .filter((entry) => isOpReference(entry[1]))
41
45
  .map(([key, reference]) => [key, reference.trim()]);
42
46
  const covered = new Set(explicit.map(([key]) => key));
43
47
  const convention = vault
44
- ? managedKeys
45
- .filter((key) => !covered.has(key) && !env[key]?.trim() && vaultTitles.has(key))
48
+ ? [...vaultTitles]
49
+ .filter((key) => isVaultEnvTitle(key) && !covered.has(key) && !env[key]?.trim())
50
+ .sort()
46
51
  .map((key) => [key, vaultReference(vault, key)])
47
52
  : [];
48
53
  return { explicit, convention };
@@ -98,8 +103,9 @@ export function listVaultItemTitles(vault, env, token) {
98
103
  if (!Array.isArray(parsed))
99
104
  throw new Error("op item list did not return a list");
100
105
  return new Set(parsed.flatMap((item) => {
101
- const title = item && typeof item === "object" ? item.title : undefined;
102
- return typeof title === "string" ? [title] : [];
106
+ const { title, category } = item && typeof item === "object" ? item : {};
107
+ // SSH keys carry no password field, so op://<vault>/<title>/password cannot resolve them.
108
+ return typeof title === "string" && category !== "SSH_KEY" ? [title] : [];
103
109
  }));
104
110
  }
105
111
  function injectBatch(entries, env, token) {
@@ -169,13 +175,13 @@ function emptyResolution(settings) {
169
175
  error: null,
170
176
  };
171
177
  }
172
- export function resolveOnePasswordEnv(env, managedKeys) {
178
+ export function resolveOnePasswordEnv(env) {
173
179
  const settings = readOnePasswordSettings(env);
174
180
  const hasReferences = Object.values(env).some((value) => isOpReference(value));
175
181
  if (!hasReferences && !settings.vault)
176
182
  return emptyResolution(settings);
177
183
  const listing = vaultTitles(settings, env);
178
- const plan = planReferences(env, managedKeys, settings.vault, listing.titles);
184
+ const plan = planReferences(env, settings.vault, listing.titles);
179
185
  const result = resolveEntries([...plan.explicit, ...plan.convention], env, settings.token.value);
180
186
  for (const [key] of plan.explicit) {
181
187
  const value = result.values.get(key);
@@ -11,7 +11,7 @@ export function parseProviderTaskStatus(value) {
11
11
  if (!value || typeof value !== "object" || Array.isArray(value))
12
12
  return null;
13
13
  const record = value;
14
- if (record.provider !== "kie" && record.provider !== "fal")
14
+ if (record.provider !== "kie" && record.provider !== "fal" && record.provider !== "machgen")
15
15
  return null;
16
16
  return {
17
17
  provider: record.provider,
@@ -1,3 +1,4 @@
1
+ import { MEDIA_TASK_TYPES } from "./media/adapters/types.js";
1
2
  import { parseModelId } from "./media/model-id.js";
2
3
  import { legacyMediaForTaskType } from "./media/legacy-task-types.js";
3
4
  import { readProviderTaskRecord } from "./provider-task-record.js";
@@ -22,14 +23,16 @@ export async function readProviderTaskStatus(engine, artifactId, hints = {}) {
22
23
  return null;
23
24
  const model = firstString(endpoint, payload.model, catalogModel?.apiId, saved?.meta.apiModel, saved?.meta.model, hints.model);
24
25
  const taskId = firstString(saved?.taskId, storedId.taskId, hints.taskId);
26
+ const machgenTask = saved?.taskType === MEDIA_TASK_TYPES.machgen;
25
27
  if (saved && /^(?:xai|alibaba)_/u.test(saved.taskType))
26
28
  return null;
27
- if (saved && saved.taskType !== "media_kie" && saved.taskType !== "isolate_vocals"
29
+ if (saved && !machgenTask && saved.taskType !== "media_kie" && saved.taskType !== "isolate_vocals"
28
30
  && !saved.taskType.startsWith("fal_") && legacyMediaForTaskType(saved.taskType) === null)
29
31
  return null;
30
32
  const provider = endpoint || saved?.taskType.startsWith("fal_") ? "fal"
31
- : catalogModel?.provider ?? hints.provider ?? (storedId.taskId || saved ? "kie" : null);
32
- if (provider !== "kie" && provider !== "fal")
33
+ : machgenTask ? "machgen"
34
+ : catalogModel?.provider ?? hints.provider ?? (storedId.taskId || saved ? "kie" : null);
35
+ if (provider !== "kie" && provider !== "fal" && provider !== "machgen")
33
36
  return null;
34
37
  return { provider, model, taskId: taskId === "queued" ? null : taskId };
35
38
  }
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createCost, PRICING, PRICING_AS_OF } from "@apicity/cost";
6
6
  import { modelEntry } from "./media/catalog.js";
7
+ import { estimateFalRequest } from "./media/cost.js";
7
8
  import { currentBookContext } from "./book-context.js";
8
9
  function installedPackageVersion(name) {
9
10
  let dir = dirname(fileURLToPath(import.meta.resolve(name)));
@@ -91,16 +92,27 @@ function tokenPrice(provider, model, tokens) {
91
92
  const usd = ((tokens.inputTokens - cached) * rate.rate.input + cached * (rate.rate.cacheRead ?? rate.rate.input) + tokens.outputTokens * rate.rate.output) / 1e6;
92
93
  return { ...unknownPrice([]), costUsd: usd, basis: "estimated", rateSource: rate.source.url, unit: "tokens", units: tokens.inputTokens + tokens.outputTokens };
93
94
  }
94
- function priceRequest(provider, model, payload, endpoint, hints) {
95
+ function historicalAverage(warnings) {
96
+ return warnings.length > 0 && warnings.every((warning) => warning.includes("historical average per call"));
97
+ }
98
+ async function priceRequest(provider, model, payload, endpoint, hints) {
95
99
  try {
96
100
  const request = { provider, payload, ...(provider === "fal" ? { endpoint } : {}), ...(hints ? { costHints: hints } : {}) };
97
- const estimate = cost.estimate(request);
98
- if (estimate.warnings.length || estimate.source === "tokens-heuristic+table")
101
+ const estimate = provider === "fal"
102
+ ? await estimateFalRequest(endpoint, payload, hints)
103
+ : cost.estimate(request);
104
+ const historical = estimate.usd > 0 && historicalAverage(estimate.warnings);
105
+ if (!historical && (estimate.warnings.length || estimate.source === "tokens-heuristic+table")) {
99
106
  return unknownPrice(estimate.warnings.length ? estimate.warnings : ["Waiting for reported token usage"]);
107
+ }
100
108
  return {
101
- ...unknownPrice([]), costUsd: estimate.usd, basis: estimate.source === "free" ? "free" : "estimated",
102
- rateSource: rateFor(provider, model)?.source.url ?? null, pricingVersion: `apicity-${COST_PACKAGE_VERSION}:${estimate.rateAsOf}`,
103
- units: estimate.breakdown.units ?? null, unit: estimate.breakdown.unit ?? null,
109
+ ...unknownPrice(historical ? estimate.warnings : []),
110
+ costUsd: estimate.usd,
111
+ basis: estimate.source === "free" ? "free" : "estimated",
112
+ rateSource: rateFor(provider, model)?.source.url ?? null,
113
+ pricingVersion: `apicity-${COST_PACKAGE_VERSION}:${estimate.rateAsOf ?? (historical ? "fal-historical" : PRICING_AS_OF)}`,
114
+ units: estimate.breakdown.units ?? null,
115
+ unit: estimate.breakdown.unit ?? null,
104
116
  };
105
117
  }
106
118
  catch {
@@ -134,6 +146,8 @@ function isBillable(provider, path, method) {
134
146
  return path.includes("/services/aigc/");
135
147
  if (provider === "elevenlabs")
136
148
  return /\/(text-to-speech|speech-to-text|speech-to-speech|sound-generation|audio-isolation|music)(\/|$)/u.test(path);
149
+ if (provider === "machgen")
150
+ return path === "/api/v0/generate";
137
151
  return /\/(videos|images)(\/|$)/u.test(path);
138
152
  }
139
153
  async function payloadOf(input, init) {
@@ -165,7 +179,7 @@ function remoteId(body, url, method) {
165
179
  ?? (/\/(requests|tasks|jobs|videos)\/([^/]+)(?:\/status)?$/u.exec(url.pathname)?.[2] ?? null);
166
180
  if (method === "GET" && pathId)
167
181
  return decodeURIComponent(pathId);
168
- return text(data.taskId) ?? text(data.task_id) ?? text(body.request_id) ?? text(body.job_id)
182
+ return text(data.taskId) ?? text(data.task_id) ?? text(body.task_id) ?? text(body.request_id) ?? text(body.job_id)
169
183
  ?? text(output.task_id) ?? text(body.id);
170
184
  }
171
185
  function responseStatus(body, accepted) {
@@ -235,12 +249,12 @@ export function providerUsageFetch(provider, transport = fetch) {
235
249
  catch {
236
250
  void 0;
237
251
  }
238
- const activity = activityFor(model, url.pathname, context.operation);
252
+ const activity = provider === "machgen" ? "video" : activityFor(model, url.pathname, context.operation);
239
253
  entry = {
240
254
  id: randomUUID(), provider, model, activity, status: "pending",
241
255
  tokenBased: /\/(responses|completions|embeddings)$/u.test(url.pathname) || rateFor(provider, model)?.kind === "tokens",
242
256
  operation: context.operation ?? null, artifactId: context.artifactId ?? null,
243
- ...priceRequest(provider, model, payload, endpoint, hints),
257
+ ...await priceRequest(provider, model, payload, endpoint, hints),
244
258
  };
245
259
  safeRecord(engine, entry);
246
260
  }
@@ -258,7 +272,7 @@ export function providerUsageFetch(provider, transport = fetch) {
258
272
  const body = json ? object(await response.clone().json()) : {};
259
273
  const externalId = remoteId(body, url, method);
260
274
  if (entry) {
261
- const accepted = !!externalId && (provider === "kie" || provider === "fal" || /videos|tasks/u.test(url.pathname) || !!object(body.output).task_id);
275
+ const accepted = !!externalId && (provider === "kie" || provider === "fal" || provider === "machgen" || /videos|tasks/u.test(url.pathname) || !!object(body.output).task_id);
262
276
  const status = response.ok ? responseStatus(body, accepted) : "failed";
263
277
  safeRecord(engine, { ...entry, ...(externalId ? { externalId } : {}), ...observedPatch(provider, entry, body, status) });
264
278
  }
package/dist/queue.js CHANGED
@@ -443,6 +443,9 @@ async function createBookRunner(bookId) {
443
443
  const runner = engine.jobs.queue.createRunner({
444
444
  concurrency: 5,
445
445
  pollIntervalMs: 250,
446
+ // The engine default lease is 30s. Remotion can stall the event loop
447
+ // longer than that, and the reaper then fails the subtitle render.
448
+ leaseMs: 10 * 60_000,
446
449
  preferredJobTypes: SEARCH_REFERENCE_JOB_TYPES,
447
450
  resolveHandler: makeWrappedResolver(bookId, engine),
448
451
  });
@@ -1088,7 +1088,7 @@ const CANONICAL_COMMANDS = [
1088
1088
  },
1089
1089
  {
1090
1090
  command: "/transcribe",
1091
- description: "Transcribe audio using ElevenLabs",
1091
+ description: "Open a chat review to transcribe a video or audio clip",
1092
1092
  example: "/transcribe @a1",
1093
1093
  category: "action",
1094
1094
  icon: "Mic",
@@ -6,6 +6,7 @@ import { DEFAULT_IMAGE_EDIT_MODEL_ID, findImageEditModel } from "../image-edit-m
6
6
  import { tryResolveModel } from "../media/descriptor.js";
7
7
  import { estimateGeneration } from "../media/cost.js";
8
8
  import { checkSeedanceEditSource, normalizeRefs } from "../media/refs.js";
9
+ import { seedanceDraftCostHints } from "../media/seedance-draft.js";
9
10
  import { REF_ROLES } from "../media/types.js";
10
11
  const GENERATION_TOOLS = new Set(["generate_image", "generate_video", "generate_audio"]);
11
12
  const REWRITE_MODEL = "gpt-5.6-sol";
@@ -70,15 +71,16 @@ async function estimateMedia(engine, input) {
70
71
  const sourceError = await checkSeedanceEditSource(engine, model, refs, input.mode);
71
72
  if (sourceError)
72
73
  return unavailable(sourceError);
73
- const result = estimateGeneration(model, {
74
+ const hints = { ...await mediaHints(engine, refs), ...await seedanceDraftCostHints(engine, model.id, options) };
75
+ const result = await estimateGeneration(model, {
74
76
  prompt: text(input.prompt),
75
77
  refUrls: placeholderRefs(refs),
76
78
  options,
77
79
  mode: text(input.mode),
78
- }, await mediaHints(engine, refs));
80
+ }, hints);
79
81
  return result.ok ? result.estimate : unavailable(result.error);
80
82
  }
81
- function estimateImageEdit(input) {
83
+ async function estimateImageEdit(input) {
82
84
  if (input.model === "sharp/layer-composite" && input.operation === "remove")
83
85
  return { ...FREE_ESTIMATE };
84
86
  const editModel = findImageEditModel(text(input.model) ?? DEFAULT_IMAGE_EDIT_MODEL_ID);
@@ -86,7 +88,7 @@ function estimateImageEdit(input) {
86
88
  if (!model)
87
89
  return unavailable(`unknown image edit model '${String(input.model)}'`);
88
90
  const imageCount = Array.isArray(input.image_refs) && input.image_refs.length > 0 ? input.image_refs.length : 1;
89
- const result = estimateGeneration(model, {
91
+ const result = await estimateGeneration(model, {
90
92
  prompt: text(input.prompt) ?? "edit",
91
93
  refUrls: { image: Array.from({ length: imageCount }, (_, index) => `https://example.test/edit-${index + 1}.png`) },
92
94
  });
@@ -145,7 +147,7 @@ export function estimateCostDef(engine, books) {
145
147
  };
146
148
  return {
147
149
  name: "estimate_cost",
148
- description: "Estimate the USD cost of one or more generate_image, generate_video or generate_audio calls, given as {tool_name, input}, before running them. Models without published rates return $0 with a warning.",
150
+ description: "Estimate the USD cost of one or more generate_image, generate_video or generate_audio calls, given as {tool_name, input}, before running them. Dynamically billed fal models use fal's historical average per call when FAL_ADMIN_API_KEY is set, and that estimate warns that it ignores the request. Without that key, or without a published rate, the estimate is $0 with a warning.",
149
151
  schema,
150
152
  handler: async (args) => {
151
153
  const calls = args.calls;
@@ -13,7 +13,9 @@ const KEY_REGISTRY = {
13
13
  DROPBOX_CLIENT_SECRET: { provider: "dropbox", label: "Dropbox OAuth" },
14
14
  KIMI_CODING_API_KEY: { provider: "kimicoding", label: "Kimi Coding" },
15
15
  FAL_API_KEY: { provider: "fal", label: "FAL" },
16
+ FAL_ADMIN_API_KEY: { provider: "fal", label: "FAL admin" },
16
17
  DASHSCOPE_API_KEY: { provider: "dashscope", label: "DashScope" },
18
+ MACHGEN_API_KEY: { provider: "machgen", label: "MachGen" },
17
19
  B2_ACCESS_KEY_ID: { provider: "b2", label: "Backblaze B2" },
18
20
  B2_SECRET_ACCESS_KEY: { provider: "b2", label: "Backblaze B2" },
19
21
  B2_REGION: { provider: "b2", label: "Backblaze B2" },
@@ -32,7 +34,9 @@ const ROW_ORDER = [
32
34
  "DROPBOX_CLIENT_SECRET",
33
35
  "KIMI_CODING_API_KEY",
34
36
  "FAL_API_KEY",
37
+ "FAL_ADMIN_API_KEY",
35
38
  "DASHSCOPE_API_KEY",
39
+ "MACHGEN_API_KEY",
36
40
  "B2_ACCESS_KEY_ID",
37
41
  "B2_SECRET_ACCESS_KEY",
38
42
  "B2_REGION",
@@ -13,6 +13,7 @@ import { KieError } from "@apicity/kie";
13
13
  import { XaiError } from "@apicity/xai";
14
14
  import { AlibabaError } from "@apicity/alibaba";
15
15
  import { FalError } from "@apicity/fal";
16
+ import { MachGenError } from "@apicity/machgen";
16
17
  import { EMPTY_TAG_SUMMARY, readAssetTagSummaries, } from "../asset-tags.js";
17
18
  import { logger } from "../logger.js";
18
19
  import { queue } from "../queue.js";
@@ -26,6 +27,8 @@ import { findUnprocessedUploads, processExternalUpload } from "../jobs/process-u
26
27
  import { planVideoTranscriptJobs } from "../jobs/process-video.js";
27
28
  import { probeVideo } from "../ffmpeg.js";
28
29
  import { providerEndpoints } from "../media/endpoints.js";
30
+ import { machgenProvider } from "../media/adapters/machgen.js";
31
+ import { MEDIA_TASK_TYPES } from "../media/adapters/types.js";
29
32
  import { submitQueuedKieTask } from "../jobs/kie-paygate.js";
30
33
  import { DEFAULT_KEYWORD_GRAPHICS_LINGER_SECONDS, DEFAULT_KEYWORD_GRAPHICS_MAX_COUNT, } from "../remotion/keyword-graphics.js";
31
34
  import { invalidVideoProxyMetadata, probeStoredVideoFile, videoProxyAssessmentMatches, videoProxyMetadata, videoProxyMetadataMatches, videoProxyValidationError, } from "../video-proxy.js";
@@ -742,9 +745,73 @@ export async function buildAssetList(engine, book_id, sort) {
742
745
  return [...assetRows, ...notebookRows].sort((left, right) => direction *
743
746
  (Date.parse(left.created_at) - Date.parse(right.created_at)));
744
747
  }
748
+ const MACHGEN_RETRYABLE_STATUSES = new Set([408, 429, 499]);
749
+ function machgenPollFailureIsFinal(error) {
750
+ if (!(error instanceof MachGenError))
751
+ return false;
752
+ const status = error.status;
753
+ return status >= 400 && status < 500 && !MACHGEN_RETRYABLE_STATUSES.has(status);
754
+ }
755
+ async function processMachGenTaskStatus(engine, task, status, result, bookId) {
756
+ const prompt = metaPrompt(task.meta);
757
+ if (status.status === "FAILED") {
758
+ const failMsg = status.error_msg || "MachGen task failed";
759
+ result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
760
+ await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
761
+ return;
762
+ }
763
+ if (status.status !== "COMPLETED") {
764
+ result.pending.push({
765
+ assetId: task.artifactId,
766
+ taskType: task.taskType,
767
+ progress: 0,
768
+ state: status.status.toLowerCase(),
769
+ prompt,
770
+ startedAt: task.createdAt,
771
+ });
772
+ return;
773
+ }
774
+ const assetDirResult = await engine.workspaces.resolveArtifact(task.artifactId);
775
+ if (!assetDirResult.ok) {
776
+ await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: `Asset not found: ${task.artifactId}`, prompt });
777
+ return;
778
+ }
779
+ await engine.jobs.pending.markCompleting(task.artifactId);
780
+ const jobId = `complete-machgen-${task.artifactId}-${Date.now()}`;
781
+ const video = status.task_output?.video;
782
+ await queue.enqueue(jobId, {
783
+ type: "complete_kie_task",
784
+ taskId: task.taskId,
785
+ taskType: task.taskType,
786
+ assetId: task.artifactId,
787
+ assetDir: assetDirResult.value,
788
+ bookId,
789
+ meta: task.meta,
790
+ resultUrls: typeof video === "string" && video ? [video] : [],
791
+ });
792
+ result.completed.push({ assetId: task.artifactId, taskType: task.taskType, jobId });
793
+ }
794
+ async function pollMachGenTasks(engine, tasks, result, bookId, baseURL) {
795
+ const machgenTasks = tasks.filter((task) => task.taskType === MEDIA_TASK_TYPES.machgen);
796
+ if (!getApiKey("MACHGEN_API_KEY") || machgenTasks.length === 0)
797
+ return;
798
+ const provider = machgenProvider(30_000, baseURL);
799
+ await Promise.all(machgenTasks.map(async (task) => {
800
+ try {
801
+ await processMachGenTaskStatus(engine, task, await provider.api.v0.tasks(task.taskId), result, bookId);
802
+ }
803
+ catch (error) {
804
+ const failMsg = error instanceof Error ? error.message : "Poll failed";
805
+ const prompt = metaPrompt(task.meta);
806
+ result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
807
+ if (machgenPollFailureIsFinal(error))
808
+ await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
809
+ }
810
+ }));
811
+ }
745
812
  /**
746
813
  * Poll provider-pending tasks: submit queued KIE rows, age out stale tasks,
747
- * and poll kie/veo/xai/alibaba/fal recordInfo lanes that mark
814
+ * and poll kie/veo/xai/alibaba/fal/machgen recordInfo lanes that mark
748
815
  * completing + enqueue complete_kie_task. Does NOT process external uploads,
749
816
  * auto-render, or auto-transcribe.
750
817
  */
@@ -1114,6 +1181,7 @@ export async function pollPendingProviderTasks(engine, book_id, options = {}) {
1114
1181
  }
1115
1182
  }));
1116
1183
  }
1184
+ await pollMachGenTasks(engine, pollableTasks, result, book_id, options.machgenBaseURL ?? providerEndpoints().machgenBaseURL);
1117
1185
  for (const task of expiredTasks) {
1118
1186
  if (options.signal?.aborted)
1119
1187
  break;
@@ -4,6 +4,7 @@ import { createKie as kie } from "@apicity/kie";
4
4
  import { createFal as fal } from "@apicity/fal";
5
5
  import { createAlibaba as alibaba } from "@apicity/alibaba";
6
6
  import { createElevenLabs as elevenlabs } from "@apicity/elevenlabs";
7
+ import { createMachGen as machgen } from "@apicity/machgen";
7
8
  import { providerUsageFetch } from "./provider-usage.js";
8
9
  export function createOpenAi(options) {
9
10
  return openai({ ...options, fetch: providerUsageFetch("openai", options.fetch) });
@@ -23,3 +24,6 @@ export function createAlibaba(options) {
23
24
  export function createElevenLabs(options) {
24
25
  return elevenlabs({ ...options, fetch: providerUsageFetch("elevenlabs", options.fetch) });
25
26
  }
27
+ export function createMachGen(options) {
28
+ return machgen({ ...options, fetch: providerUsageFetch("machgen", options.fetch) });
29
+ }
@@ -246,7 +246,7 @@ function toolAction(tool, command, reserved) {
246
246
  ...(mediaType ? { generation: { provider: "catalog", operation: tool.name, mediaType } } : {}),
247
247
  surfaces: target.kind === "asset" ? ["slash", "launcher", "cell-menu"]
248
248
  : target.kind === "cell" ? ["slash", "launcher", "cell-menu", "selection-menu"] : ["slash", "launcher"],
249
- focus: "dialog",
249
+ focus: tool.name === "transcribe" ? "composer" : "dialog",
250
250
  confirmation: confirmationForTool(tool),
251
251
  };
252
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "videobook",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Local-first MCP server and web UI for AI video creation: notebooks, generation, editing, rendering and publishing.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -36,18 +36,19 @@
36
36
  "start": "node dist/index.js"
37
37
  },
38
38
  "dependencies": {
39
- "@apicity/alibaba": "^0.16.2",
40
- "@apicity/b2": "^0.16.2",
41
- "@apicity/cost": "^0.16.2",
42
- "@apicity/dropbox": "^0.16.2",
43
- "@apicity/elevenlabs": "^0.16.2",
44
- "@apicity/fal": "^0.16.2",
45
- "@apicity/free-media-upload": "^0.16.2",
46
- "@apicity/kie": "^0.16.2",
47
- "@apicity/kimicoding": "^0.16.2",
48
- "@apicity/openai": "^0.16.2",
49
- "@apicity/x": "^0.16.2",
50
- "@apicity/xai": "^0.16.2",
39
+ "@apicity/alibaba": "^0.17.0",
40
+ "@apicity/b2": "^0.17.0",
41
+ "@apicity/cost": "^0.17.0",
42
+ "@apicity/dropbox": "^0.17.0",
43
+ "@apicity/elevenlabs": "^0.17.0",
44
+ "@apicity/fal": "^0.17.0",
45
+ "@apicity/free-media-upload": "^0.17.0",
46
+ "@apicity/kie": "^0.17.0",
47
+ "@apicity/kimicoding": "^0.17.0",
48
+ "@apicity/machgen": "^0.17.0",
49
+ "@apicity/openai": "^0.17.0",
50
+ "@apicity/x": "^0.17.0",
51
+ "@apicity/xai": "^0.17.0",
51
52
  "@devmehq/open-graph-extractor": "^1.1.1",
52
53
  "@ffprobe-installer/ffprobe": "2.1.2",
53
54
  "@modelcontextprotocol/client": "^2.2.0",