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 +25 -0
- package/README.md +7 -8
- package/dist/book-manager.js +40 -2
- package/dist/env-secrets.js +1 -0
- package/dist/image-edit-models.js +1 -0
- package/dist/media/catalog.js +13 -0
- package/dist/media/cost.js +79 -6
- package/dist/media/json-schema.js +7 -0
- package/dist/media/ref-fields.js +11 -3
- package/dist/media/request.js +49 -0
- package/dist/provider-usage.js +19 -7
- package/dist/tools/estimate-cost.js +4 -4
- package/dist/tools/provider-key-status.js +2 -0
- package/package.json +13 -13
- package/ui/assets/{alive-captions-C48o6dfv.js → alive-captions-4QySEGdt.js} +1 -1
- package/ui/assets/confirm-bodies-Bw818V9J.js +1 -0
- package/ui/assets/{fullscreen-4m0qtZ7w.js → fullscreen-CzTnPPG5.js} +1 -1
- package/ui/assets/{goal-ui-2pUWP_B2.js → goal-ui-CosrK_ko.js} +1 -1
- package/ui/assets/index-C9xjFEGL.js +37 -0
- package/ui/assets/{inspector-CnKySYc5.js → inspector-BN2fezh4.js} +1 -1
- package/ui/assets/{jobs-ui-lRoS1MbL.js → jobs-ui-C4Wgy6I1.js} +1 -1
- package/ui/assets/{music-video-BQnnPp-X.js → music-video-BSj8SgbQ.js} +1 -1
- package/ui/assets/{ui-OHFUngrc.js → ui-Cpr6zMTA.js} +1 -1
- package/ui/assets/{videobook-DZ58pKW5.js → videobook-B5G0jMoi.js} +13 -13
- package/ui/index.html +8 -8
- package/ui/assets/confirm-bodies-aq6Az8Md.js +0 -1
- package/ui/assets/index-DmDH4gYr.js +0 -37
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)
|
|
16
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
package/dist/book-manager.js
CHANGED
|
@@ -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
|
|
156
|
+
await moveDirectory(temporaryRoot, destination);
|
|
119
157
|
finalRoot = destination;
|
|
120
158
|
const engine = this.createEngine(finalRoot);
|
|
121
159
|
finalEngine = engine;
|
package/dist/env-secrets.js
CHANGED
|
@@ -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;
|
package/dist/media/catalog.js
CHANGED
|
@@ -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"] },
|
package/dist/media/cost.js
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
|
-
import {
|
|
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
|
|
99
|
+
payload,
|
|
31
100
|
endpoint: model.apiId,
|
|
32
|
-
costHints
|
|
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 {
|
|
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
|
}
|
package/dist/media/ref-fields.js
CHANGED
|
@@ -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(
|
|
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 }) => ({
|
|
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);
|
package/dist/media/request.js
CHANGED
|
@@ -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;
|
package/dist/provider-usage.js
CHANGED
|
@@ -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
|
|
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 =
|
|
98
|
-
|
|
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(
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
40
|
-
"@apicity/b2": "^0.16.
|
|
41
|
-
"@apicity/cost": "^0.16.
|
|
42
|
-
"@apicity/dropbox": "^0.16.
|
|
43
|
-
"@apicity/elevenlabs": "^0.16.
|
|
44
|
-
"@apicity/fal": "^0.16.
|
|
45
|
-
"@apicity/free-media-upload": "^0.16.
|
|
46
|
-
"@apicity/kie": "^0.16.
|
|
47
|
-
"@apicity/kimicoding": "^0.16.
|
|
48
|
-
"@apicity/openai": "^0.16.
|
|
49
|
-
"@apicity/x": "^0.16.
|
|
50
|
-
"@apicity/xai": "^0.16.
|
|
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",
|