videobook 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ and the package uses [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.3] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - Fal FLUX 3 Max text-to-video, image-to-video, and keyframes-to-video,
14
+ with generated audio and resolution-tiered per-second cost estimates.
15
+ - Bria Fibo 2 text-to-image with structured VGL prompts, and Fibo 2 Edit
16
+ for one to four reference images, including the image editor.
17
+ - H3 Max Reframe Video, which outpaints a source video to a target aspect
18
+ ratio at 480p, 768p, or 1080p while keeping its audio and timing.
19
+ Priced from fal's historical average because the request does not fix
20
+ the billed duration.
21
+
22
+ ### Changed
23
+
24
+ - Upgrade all application `@apicity/*` dependencies to 0.16.3.
25
+ - Windows x64 is supported. The release check runs the pack-and-install
26
+ smoke on Windows and blocks the release when it fails. Stopping
27
+ `videobook serve` from another process still ends it without a clean
28
+ shutdown, because Windows has no SIGTERM, and the next start replaces
29
+ the lock file.
30
+ - Dynamic fal requests are priced from fal's historical average when
31
+ `FAL_ADMIN_API_KEY` is set. Without the key, and for the two Seedance
32
+ formula gaps, the estimate stays at the local $0.
33
+
9
34
  ## [0.2.2] - 2026-10-21
10
35
 
11
36
  ### 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,6 +121,7 @@ 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
126
  `X_CLIENT_ID` and `X_CLIENT_SECRET`, `DROPBOX_CLIENT_ID` and
126
127
  `DROPBOX_CLIENT_SECRET`, and the `B2_*` storage settings. The server starts
@@ -177,12 +178,10 @@ come from 1Password are read-only in Settings.
177
178
  - macOS on Intel works, but ONNX Runtime has no Intel Mac build, so semantic
178
179
  search is unavailable there.
179
180
  - 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.
181
+ - Windows x64 is supported. Windows has no SIGTERM, so stopping
182
+ `videobook serve` from another process ends it without a clean shutdown,
183
+ and the next start replaces the lock file it leaves. The Release check
184
+ workflow runs the pack-and-install smoke on Windows for every release tag.
186
185
 
187
186
  ## Security
188
187
 
@@ -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;
@@ -35,6 +35,7 @@ export const MANAGED_ENV_KEYS = [
35
35
  "DROPBOX_CLIENT_SECRET",
36
36
  "KIMI_CODING_API_KEY",
37
37
  "FAL_API_KEY",
38
+ "FAL_ADMIN_API_KEY",
38
39
  "DASHSCOPE_API_KEY",
39
40
  "ELEVENLABS_API_KEY",
40
41
  "B2_ACCESS_KEY_ID",
@@ -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;
@@ -8,6 +8,13 @@ const SEEDANCE_MODES = [
8
8
  const FRAME_MODES = [
9
9
  { id: "i2v", label: "Frames", roles: ["first_frame", "last_frame"] },
10
10
  ];
11
+ const FLUX_3_MAX_DEFAULTS = {
12
+ aspect_ratio: "auto",
13
+ duration: 5,
14
+ generate_audio: true,
15
+ resolution: "720p",
16
+ reasoning_level: "high",
17
+ };
11
18
  const SEEDANCE_US_REFERENCE_MODES = [
12
19
  { id: "r2v", label: "References", roles: ["image", "video", "audio"], requireAny: ["image", "video", "audio"] },
13
20
  { id: "v2v", label: "Edit video", roles: ["video", "image", "audio"], requireAny: ["video"], defaults: { task: "editing" } },
@@ -110,6 +117,8 @@ export const MODEL_ENTRIES = [
110
117
  { id: "fal:ideogram/v4.5/edit", media: "image", operations: ["edit_image"], refs: { image: ["image_url", "reference_image_urls"] } },
111
118
  { id: "fal:blackforestlabs/flux-3/text-to-image", media: "image", operations: ["text_to_image"] },
112
119
  { id: "fal:blackforestlabs/flux-3/edit-image", media: "image", operations: ["edit_image"] },
120
+ { id: "fal:bria/fibo-2/generate", media: "image", operations: ["text_to_image"] },
121
+ { id: "fal:bria/fibo-2/edit", media: "image", operations: ["edit_image"], prompt: "instruction" },
113
122
  { id: "fal:fal-ai/flux-2-flex", media: "image", operations: ["text_to_image"] },
114
123
  { id: "fal:bria/guided-remove-background", media: "image", operations: ["edit_image"] },
115
124
  { id: "fal:bria/fibo-edit-1.5/expand", media: "image", operations: ["edit_image"] },
@@ -172,6 +181,10 @@ export const MODEL_ENTRIES = [
172
181
  { id: "fal:alibaba/wan-3.0-prime/text-to-video", media: "video", operations: ["text_to_video"], hidden: true },
173
182
  { id: "fal:alibaba/wan-3.0-prime/image-to-video", media: "video", operations: ["image_to_video"], hidden: true },
174
183
  { id: "fal:alibaba/wan-3.0-prime/reference-to-video", media: "video", operations: ["reference_to_video"], hidden: true },
184
+ { id: "fal:blackforestlabs/flux-3-max/text-to-video", media: "video", operations: ["text_to_video"], title: "FLUX 3 Max", defaults: FLUX_3_MAX_DEFAULTS },
185
+ { id: "fal:blackforestlabs/flux-3-max/image-to-video", media: "video", operations: ["image_to_video"], title: "FLUX 3 Max Image", refs: { first_frame: "image_url" }, modes: FRAME_MODES, defaults: FLUX_3_MAX_DEFAULTS },
186
+ { id: "fal:blackforestlabs/flux-3-max/keyframes-to-video", media: "video", operations: ["image_to_video"], title: "FLUX 3 Max Keyframes", refs: { image: "keyframes" }, keyframeFps: 24, defaults: FLUX_3_MAX_DEFAULTS },
187
+ { id: "fal:minimax/h3-max/reframe-video", media: "video", operations: ["edit_video"] },
175
188
  { id: "fal:minimax/h3-max/insert-video", media: "video", operations: ["edit_video"], refs: { video: ["video_url", "reference_video_urls"] } },
176
189
  { id: "fal:minimax/h3-max/recast", media: "video", operations: ["edit_video"] },
177
190
  { id: "fal:minimax/h3-max-turbo/extend-video", media: "video", operations: ["extend_video"] },
@@ -1,5 +1,70 @@
1
- import { computeEstimate } from "@apicity/cost";
1
+ import { createFal } from "@apicity/fal";
2
+ import { computeEstimate, createFalEstimateCache, resolveFalDynamicEstimate, } from "@apicity/cost";
2
3
  import { buildWireRequest } from "./request.js";
4
+ const FAL_ESTIMATE_TTL_MS = 5 * 60 * 1000;
5
+ let falEstimateCache = createFalEstimateCache({
6
+ ttlMs: FAL_ESTIMATE_TTL_MS,
7
+ maxEntries: 128,
8
+ });
9
+ let estimatorOverrideActive = false;
10
+ let estimatorOverride = null;
11
+ let liveEstimatorKey;
12
+ let liveEstimator;
13
+ function freshFalEstimateCache() {
14
+ return createFalEstimateCache({ ttlMs: FAL_ESTIMATE_TTL_MS, maxEntries: 128 });
15
+ }
16
+ export function installFalDynamicEstimator(estimator) {
17
+ const previous = {
18
+ active: estimatorOverrideActive,
19
+ estimator: estimatorOverride,
20
+ cache: falEstimateCache,
21
+ };
22
+ estimatorOverrideActive = true;
23
+ estimatorOverride = estimator;
24
+ falEstimateCache = freshFalEstimateCache();
25
+ return () => {
26
+ estimatorOverrideActive = previous.active;
27
+ estimatorOverride = previous.estimator;
28
+ falEstimateCache = previous.cache;
29
+ };
30
+ }
31
+ function falAdminKey() {
32
+ const value = process.env.FAL_ADMIN_API_KEY?.trim();
33
+ return value ? value : undefined;
34
+ }
35
+ function falPricingEstimator(apiKey) {
36
+ const estimate = createFal({ apiKey }).v1.models.pricing.estimate;
37
+ return (request) => estimate(request);
38
+ }
39
+ function currentFalEstimator() {
40
+ if (estimatorOverrideActive)
41
+ return estimatorOverride;
42
+ const apiKey = falAdminKey();
43
+ if (!apiKey)
44
+ return null;
45
+ if (liveEstimator && liveEstimatorKey === apiKey)
46
+ return liveEstimator;
47
+ liveEstimatorKey = apiKey;
48
+ liveEstimator = falPricingEstimator(apiKey);
49
+ return liveEstimator;
50
+ }
51
+ export async function estimateFalRequest(endpoint, payload, hints) {
52
+ const estimator = currentFalEstimator();
53
+ if (!estimator) {
54
+ return computeEstimate({
55
+ provider: "fal",
56
+ payload,
57
+ endpoint,
58
+ ...(hints ? { costHints: hints } : {}),
59
+ });
60
+ }
61
+ return resolveFalDynamicEstimate(endpoint, payload, estimator, {
62
+ ...(hints ? { hints } : {}),
63
+ // Queue polls omit x-fal-billable-units, so this is one historical call.
64
+ callQuantity: 1,
65
+ cache: falEstimateCache,
66
+ });
67
+ }
3
68
  function costProvider(model) {
4
69
  return model.provider;
5
70
  }
@@ -24,20 +89,28 @@ export function generationCostHints(input, hints) {
24
89
  ? { ...combined, durationSeconds: combined.inputDurationSeconds }
25
90
  : combined;
26
91
  }
27
- function estimateWireRequest(model, request, input, hints = {}) {
92
+ async function estimateWireRequest(model, request, input, hints = {}) {
93
+ const payload = costPayload(model, request, input);
94
+ const costHints = generationCostHints(input, hints);
95
+ if (model.provider === "fal")
96
+ return estimateFalRequest(model.apiId, payload, costHints);
28
97
  return computeEstimate({
29
98
  provider: costProvider(model),
30
- payload: costPayload(model, request, input),
99
+ payload,
31
100
  endpoint: model.apiId,
32
- costHints: generationCostHints(input, hints),
101
+ costHints,
33
102
  });
34
103
  }
35
- export function estimateGeneration(model, call, hints = {}) {
104
+ export async function estimateGeneration(model, call, hints = {}) {
36
105
  const built = buildWireRequest(model, call);
37
106
  if (!built.ok)
38
107
  return built;
39
108
  try {
40
- return { ok: true, estimate: estimateWireRequest(model, built.request, built.input, hints), request: built.request };
109
+ return {
110
+ ok: true,
111
+ estimate: await estimateWireRequest(model, built.request, built.input, hints),
112
+ request: built.request,
113
+ };
41
114
  }
42
115
  catch (error) {
43
116
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -36,6 +36,13 @@ export function isUrlObject(schema) {
36
36
  return false;
37
37
  return branches(schema).some((branch) => branch.type === "object" && branch.properties?.url !== undefined);
38
38
  }
39
+ export function isKeyframeItem(schema) {
40
+ if (!schema)
41
+ return false;
42
+ return branches(schema).some((branch) => branch.type === "object"
43
+ && branch.properties?.image_url !== undefined
44
+ && branch.properties?.frame_index !== undefined);
45
+ }
39
46
  export function isArraySchema(schema) {
40
47
  return branches(schema).some((branch) => branch.type === "array");
41
48
  }
@@ -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;
@@ -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 {
@@ -240,7 +252,7 @@ export function providerUsageFetch(provider, transport = fetch) {
240
252
  id: randomUUID(), provider, model, activity, status: "pending",
241
253
  tokenBased: /\/(responses|completions|embeddings)$/u.test(url.pathname) || rateFor(provider, model)?.kind === "tokens",
242
254
  operation: context.operation ?? null, artifactId: context.artifactId ?? null,
243
- ...priceRequest(provider, model, payload, endpoint, hints),
255
+ ...await priceRequest(provider, model, payload, endpoint, hints),
244
256
  };
245
257
  safeRecord(engine, entry);
246
258
  }
@@ -70,7 +70,7 @@ async function estimateMedia(engine, input) {
70
70
  const sourceError = await checkSeedanceEditSource(engine, model, refs, input.mode);
71
71
  if (sourceError)
72
72
  return unavailable(sourceError);
73
- const result = estimateGeneration(model, {
73
+ const result = await estimateGeneration(model, {
74
74
  prompt: text(input.prompt),
75
75
  refUrls: placeholderRefs(refs),
76
76
  options,
@@ -78,7 +78,7 @@ async function estimateMedia(engine, input) {
78
78
  }, await mediaHints(engine, refs));
79
79
  return result.ok ? result.estimate : unavailable(result.error);
80
80
  }
81
- function estimateImageEdit(input) {
81
+ async function estimateImageEdit(input) {
82
82
  if (input.model === "sharp/layer-composite" && input.operation === "remove")
83
83
  return { ...FREE_ESTIMATE };
84
84
  const editModel = findImageEditModel(text(input.model) ?? DEFAULT_IMAGE_EDIT_MODEL_ID);
@@ -86,7 +86,7 @@ function estimateImageEdit(input) {
86
86
  if (!model)
87
87
  return unavailable(`unknown image edit model '${String(input.model)}'`);
88
88
  const imageCount = Array.isArray(input.image_refs) && input.image_refs.length > 0 ? input.image_refs.length : 1;
89
- const result = estimateGeneration(model, {
89
+ const result = await estimateGeneration(model, {
90
90
  prompt: text(input.prompt) ?? "edit",
91
91
  refUrls: { image: Array.from({ length: imageCount }, (_, index) => `https://example.test/edit-${index + 1}.png`) },
92
92
  });
@@ -145,7 +145,7 @@ export function estimateCostDef(engine, books) {
145
145
  };
146
146
  return {
147
147
  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.",
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. 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
149
  schema,
150
150
  handler: async (args) => {
151
151
  const calls = args.calls;
@@ -13,6 +13,7 @@ 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" },
17
18
  B2_ACCESS_KEY_ID: { provider: "b2", label: "Backblaze B2" },
18
19
  B2_SECRET_ACCESS_KEY: { provider: "b2", label: "Backblaze B2" },
@@ -32,6 +33,7 @@ const ROW_ORDER = [
32
33
  "DROPBOX_CLIENT_SECRET",
33
34
  "KIMI_CODING_API_KEY",
34
35
  "FAL_API_KEY",
36
+ "FAL_ADMIN_API_KEY",
35
37
  "DASHSCOPE_API_KEY",
36
38
  "B2_ACCESS_KEY_ID",
37
39
  "B2_SECRET_ACCESS_KEY",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "videobook",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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,18 @@
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.16.3",
40
+ "@apicity/b2": "^0.16.3",
41
+ "@apicity/cost": "^0.16.3",
42
+ "@apicity/dropbox": "^0.16.3",
43
+ "@apicity/elevenlabs": "^0.16.3",
44
+ "@apicity/fal": "^0.16.3",
45
+ "@apicity/free-media-upload": "^0.16.3",
46
+ "@apicity/kie": "^0.16.3",
47
+ "@apicity/kimicoding": "^0.16.3",
48
+ "@apicity/openai": "^0.16.3",
49
+ "@apicity/x": "^0.16.3",
50
+ "@apicity/xai": "^0.16.3",
51
51
  "@devmehq/open-graph-extractor": "^1.1.1",
52
52
  "@ffprobe-installer/ffprobe": "2.1.2",
53
53
  "@modelcontextprotocol/client": "^2.2.0",