videobook 0.2.1 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +7 -8
  3. package/dist/book-manager.js +40 -2
  4. package/dist/chat-router.js +1 -1
  5. package/dist/env-secrets.js +1 -0
  6. package/dist/guides/seedance_2_prompting_guide.md +1 -1
  7. package/dist/image-edit-models.js +2 -0
  8. package/dist/jobs/generate-media.js +18 -6
  9. package/dist/jobs/run-notebook.js +23 -4
  10. package/dist/media/adapters/fal.js +7 -0
  11. package/dist/media/catalog.js +17 -2
  12. package/dist/media/cost.js +80 -7
  13. package/dist/media/json-schema.js +7 -0
  14. package/dist/media/ref-fields.js +11 -3
  15. package/dist/media/refs.js +13 -0
  16. package/dist/media/request.js +56 -0
  17. package/dist/media/seedance-edit.js +9 -0
  18. package/dist/provider-usage.js +22 -8
  19. package/dist/tools/estimate-cost.js +8 -5
  20. package/dist/tools/generate-media.js +2 -1
  21. package/dist/tools/provider-key-status.js +2 -0
  22. package/dist/tools/reconcile-project-work.js +2 -1
  23. package/package.json +13 -13
  24. package/ui/assets/{alive-captions-e-n3xmCd.js → alive-captions-4QySEGdt.js} +1 -1
  25. package/ui/assets/confirm-bodies-Bw818V9J.js +1 -0
  26. package/ui/assets/{fullscreen-VY7f4mJ-.js → fullscreen-CzTnPPG5.js} +1 -1
  27. package/ui/assets/{goal-ui-B__NgZYz.js → goal-ui-CosrK_ko.js} +2 -2
  28. package/ui/assets/index-C9xjFEGL.js +37 -0
  29. package/ui/assets/{inspector-BBuZIaqg.js → inspector-BN2fezh4.js} +1 -1
  30. package/ui/assets/{jobs-ui-vmEH4zBT.js → jobs-ui-C4Wgy6I1.js} +1 -1
  31. package/ui/assets/{music-video-CklLKD1R.js → music-video-BSj8SgbQ.js} +1 -1
  32. package/ui/assets/ui-Cpr6zMTA.js +2 -0
  33. package/ui/assets/{vendor-flow-DY81i6dC.js → vendor-flow-zBavaNEW.js} +1 -1
  34. package/ui/assets/{vendor-mcp-BsGNAD4I.js → vendor-mcp-DQ7EusT9.js} +1 -1
  35. package/ui/assets/vendor-yctIZ9Dz.js +19 -0
  36. package/ui/assets/videobook-B5G0jMoi.js +20 -0
  37. package/ui/index.html +11 -11
  38. package/ui/assets/confirm-bodies-DS6jh0Kb.js +0 -1
  39. package/ui/assets/index-BPco0HoT.js +0 -37
  40. package/ui/assets/ui-CtYXY_po.js +0 -2
  41. package/ui/assets/vendor-OClqq_Da.js +0 -19
  42. package/ui/assets/videobook-lhLK6e6Q.js +0 -20
package/CHANGELOG.md CHANGED
@@ -6,6 +6,47 @@ 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
+
34
+ ## [0.2.2] - 2026-10-21
35
+
36
+ ### Added
37
+
38
+ - KIE FLUX 3 Klein text-to-image and image editing, with 1K defaults,
39
+ up to ten edit references, and resolution-aware per-image cost estimates.
40
+ - FLUX 3 Klein Edit in the image editor for whole-image and region edits.
41
+
42
+ ### Changed
43
+
44
+ - Upgrade all application `@apicity/*` dependencies to 0.16.2.
45
+
46
+ ### Fixed
47
+
48
+ - Accept npm 12 workspace pack results in the installed-package smoke gate.
49
+
9
50
  ## [0.2.1] - 2026-10-20
10
51
 
11
52
  ### Changed
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;
@@ -576,7 +576,7 @@ Parameter guidelines:
576
576
  - Preserve every consumed notebook @address in the generation prompt at the semantic position where it belongs. Do not strip, rename, or expand notebook references into prose
577
577
  - For generate_video: put the compact generation description in "prompt" and preserve every consumed @ reference
578
578
  - For generate_image: put the compact image description in "prompt" and preserve every consumed image @address there; use refs.image ONLY for the corresponding resolved asset tags whose asset context type is "image", in the same order (include @ prefix). Never put a video or audio asset tag in refs.image — if the user wants an image inspired by a video, describe that source without connecting it as an image
579
- - ByteDance naming: Seedance is video, Seedream is image. Users often say Seedance for both. When the query names seedance, bytedance, or seedream: if the edit/replace target is an image, use generate_image with model kie:seedream/5-lite-image-to-image and put the edit-target image first in refs.image, followed by replacement image refs. If the target is a video, use generate_video with model kie:bytedance/seedance-2-5, that clip in refs.video and replacement stills in refs.image. Never route an image-target Seedance ask to a video model or to Nano Banana Pro
579
+ - ByteDance naming: Seedance is video, Seedream is image. Users often say Seedance for both. When the query names seedance, bytedance, or seedream: if the edit/replace target is an image, use generate_image with model kie:seedream/5-lite-image-to-image and put the edit-target image first in refs.image, followed by replacement image refs. If the target is a video, use generate_video with model kie:bytedance/seedance-2-5, mode v2v, one 4–30 second clip in refs.video and replacement stills in refs.image. Edit mode follows the source aspect ratio and duration. Never route an image-target Seedance ask to a video model or to Nano Banana Pro
580
580
  - Treat "Seedance 5.0 edit", "Seedance 5", and "Seedream 5" as Seedream 5. Generic chat uses Seedream 5 Lite image-to-image; Seedream 5 Pro edit lives in the subject/region pipeline. When Seedream is used for an image ask that said Seedance, the status message must note that substitution
581
581
  - For create_watermark_card (QR code / watermark card): use this tool for real scannable QR codes — never generate_image. Pass url from the referenced asset's source_url/webpage_url when the user says "from the url in @vid-…" or similar; optional handle is an X or TikTok alias without @; set platform to "tiktok" for TikTok or "x" for X. apply_watermark uses the same platform field for its alias, icon, and default QR destination
582
582
  - For create_qart (artistic / picture QR): use when turning an image asset into scan-safe color and monochrome QR mosaics; pass image_ref and value (URL/text). Prefer over create_watermark_card when the user wants a photo integrated into the QR modules
@@ -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",
@@ -85,7 +85,7 @@ Use Seedance 2.0 reference-to-video when replacing people in a clip. Pass the so
85
85
  Replace the on-screen person matching @Image1 with @Image1 as character reference. Replace the on-screen person matching @Image2 with @Image2 as character reference. Replace the on-screen person matching @Image3 with @Image3 as character reference. Keep camera movement, pacing, and framing of @Video1.
86
86
  ```
87
87
 
88
- Notebook wiring: connect the source video plus the identity stills to a Seedance generation cell (model `kie:bytedance/seedance-2-5`, mode `r2v`). The runner fills `refs.video` from connected video cells and `refs.image` from connected image cells. The result is a normal generated video artifact (`original.mp4` / `proxy.mp4`), not an orientation render.
88
+ Notebook wiring: connect the source video plus the identity stills to a Seedance generation cell (model `kie:bytedance/seedance-2-5`, mode `v2v` / Edit video). Use one source video with a known duration of 4–30 seconds; import URL sources into the book first. Edit mode sets `aspect_ratio=adaptive` and `duration=-1` to preserve the source shape and length. Ordinary reference generation remains `r2v` with its chosen output settings. The runner fills `refs.video` from connected video cells and `refs.image` from connected image cells. The result is a normal generated video artifact (`original.mp4` / `proxy.mp4`), not an orientation render.
89
89
 
90
90
  ## Camera Movement Vocabulary
91
91
 
@@ -9,6 +9,7 @@ const STANDARD_IMAGE_EDIT_CAPABILITIES = {
9
9
  };
10
10
  const EDIT_MODELS = [
11
11
  { catalogModel: `kie:${DEFAULT_IMAGE_EDIT_MODEL_ID}`, label: "Nano Banana 2.1 Edit" },
12
+ { catalogModel: "kie:flux-3/klein-edit-image", label: "FLUX 3 Klein Edit" },
12
13
  { catalogModel: "kie:seedream/5-pro-image-to-image", label: "Seedream 5 Pro" },
13
14
  { catalogModel: "kie:gpt-image-2-5-flare-image-to-image", label: "GPT Image 2.5 Flare" },
14
15
  { catalogModel: "kie:gpt-image-2-5-sunburst-image-to-image", label: "GPT Image 2.5 Sunburst" },
@@ -17,6 +18,7 @@ const EDIT_MODELS = [
17
18
  { catalogModel: "fal:fal-ai/nano-banana-2/edit", label: "Nano Banana 2" },
18
19
  { catalogModel: "fal:alibaba/qwen-image-3/edit", label: "Qwen 3.0 Edit" },
19
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" },
20
22
  ];
21
23
  function imageSlotMax(model) {
22
24
  return model.slots.find((slot) => slot.role === "image")?.max ?? null;
@@ -1,3 +1,4 @@
1
+ import { isSeedanceEdit } from "../media/seedance-edit.js";
1
2
  import { registerJobHandler } from "../queue.js";
2
3
  import { assertProviderPayload } from "../provider-prompt-transform.js";
3
4
  import { requireApiKey } from "../api-key.js";
@@ -5,7 +6,7 @@ import { referenceVideoDuration } from "../reference-video-duration.js";
5
6
  import { resolveModel } from "../media/descriptor.js";
6
7
  import { buildWireRequest } from "../media/request.js";
7
8
  import { applyPromptDialect } from "../media/prompt-dialects.js";
8
- import { checkInsertVideoTiming, checkRefDurations, resolveRefInput, uploadRefs } from "../media/refs.js";
9
+ import { checkSeedanceEditSource, checkInsertVideoTiming, checkRefDurations, resolveRefInput, uploadRefs } from "../media/refs.js";
9
10
  import { adapterFor } from "../media/adapters/index.js";
10
11
  import { completeMediaGeneration } from "../media/complete.js";
11
12
  function storedPayload(model, request) {
@@ -17,11 +18,21 @@ async function replaceAudioMeta(engine, ref) {
17
18
  const input = await resolveRefInput(engine, "audio", ref);
18
19
  return { replaceAudioWith: { assetId: input.assetId, filename: input.filename } };
19
20
  }
20
- async function costHints(engine, refs) {
21
- if (refs.sourceVideoIds.length === 0)
21
+ export async function generationSourceCostHints(engine, refs, job) {
22
+ const videoSeconds = await referenceVideoDuration(engine, refs.sourceVideoIds);
23
+ if (videoSeconds !== undefined) {
24
+ return { inputDurationSeconds: videoSeconds, ...(isSeedanceEdit(job.model, job.mode) ? { durationSeconds: videoSeconds } : {}) };
25
+ }
26
+ if (resolveModel(job.model).provider !== "fal")
22
27
  return {};
23
- const seconds = await referenceVideoDuration(engine, refs.sourceVideoIds);
24
- return seconds === undefined ? {} : { inputDurationSeconds: seconds };
28
+ for (const assetId of refs.sourceAudioIds) {
29
+ const metadata = await engine.metadata.artifacts.read(assetId, "original");
30
+ const seconds = metadata.ok ? metadata.value.duration : undefined;
31
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) {
32
+ return { inputDurationSeconds: seconds };
33
+ }
34
+ }
35
+ return {};
25
36
  }
26
37
  function generationMeta(job, model, refs) {
27
38
  return {
@@ -40,6 +51,7 @@ export async function runGenerateMedia(engine, job, ctx) {
40
51
  await engine.metadata.artifacts.write(job.assetId, "generation-request", job);
41
52
  const prompt = job.prompt === undefined ? undefined : applyPromptDialect(model.entry.dialect, job.prompt, job.refs);
42
53
  const durationError = (await checkInsertVideoTiming(engine, model, job.refs, job.options))
54
+ ?? (await checkSeedanceEditSource(engine, model, job.refs, job.mode))
43
55
  ?? await checkRefDurations(engine, model, job.refs);
44
56
  if (durationError)
45
57
  throw new Error(durationError);
@@ -58,7 +70,7 @@ export async function runGenerateMedia(engine, job, ctx) {
58
70
  assetId: job.assetId,
59
71
  assetDir: job.assetDir,
60
72
  meta,
61
- costHints: await costHints(engine, refs),
73
+ costHints: await generationSourceCostHints(engine, refs, job),
62
74
  });
63
75
  if (result.kind === "done") {
64
76
  await completeMediaGeneration({ engine, assetId: job.assetId, assetDir: job.assetDir, bookId: job.bookId, meta, outputs: result.outputs });
@@ -14,7 +14,7 @@ import { smoothSceneConcatDef } from "../tools/smooth-scene-concat.js";
14
14
  import { spliceVideoDef } from "../tools/splice-video.js";
15
15
  import { tryResolveModel } from "../media/descriptor.js";
16
16
  import { previewGeneration } from "../media/preview.js";
17
- import { checkInsertVideoTiming, normalizeRefs } from "../media/refs.js";
17
+ import { checkSeedanceEditSource, checkInsertVideoTiming, normalizeRefs } from "../media/refs.js";
18
18
  import { REF_ROLES } from "../media/types.js";
19
19
  import { captureFrame, concatSourceRanges, exportSequenceMaster, extractSourceAudio, MINIMUM_SPLIT_PIECE_SECONDS, normalizeSceneVideo, splitSourceRange, splitSourceRanges, } from "../notebook-media-ops.js";
20
20
  import { analyzeNotebookSource } from "../notebook-live-analysis.js";
@@ -830,6 +830,9 @@ async function prepareMediaGeneration(model, document, cell, bookId, engine, opt
830
830
  const timingError = await checkInsertVideoTiming(engine, model, normalizeRefs(prepared.input.refs), preview.preview.payload.input ?? {});
831
831
  if (timingError)
832
832
  throw new Error(timingError);
833
+ const sourceError = await checkSeedanceEditSource(engine, model, normalizeRefs(prepared.input.refs), prepared.input.mode);
834
+ if (sourceError)
835
+ throw new Error(sourceError);
833
836
  }
834
837
  return {
835
838
  tool: `generate_${model.media}`,
@@ -2357,7 +2360,7 @@ export async function failQueuedNotebookCells(engine, notebookId, options) {
2357
2360
  function notebookRunPlanRecords(document) {
2358
2361
  return document.notebookRunPlans ?? [];
2359
2362
  }
2360
- async function updateNotebookRunPlanState(engine, notebookId, planId, status, details = {}) {
2363
+ async function updateNotebookRunPlanState(engine, notebookId, planId, status, details = {}, recordedRun) {
2361
2364
  const result = await mutateNotebook(engine, notebookId, "update_notebook_run_plan", (document) => ({
2362
2365
  ...document,
2363
2366
  notebookRunPlans: notebookRunPlanRecords(document).map((record) => record.planId === planId
@@ -2367,6 +2370,17 @@ async function updateNotebookRunPlanState(engine, notebookId, planId, status, de
2367
2370
  ...record,
2368
2371
  status,
2369
2372
  ...details,
2373
+ ...(recordedRun ? {
2374
+ plan: {
2375
+ ...record.plan,
2376
+ recordedRunId: recordedRun.recordedRunId,
2377
+ recordedRuns: [
2378
+ ...(record.plan.recordedRuns ?? []),
2379
+ recordedRun,
2380
+ ],
2381
+ },
2382
+ error: recordedRun.error,
2383
+ } : {}),
2370
2384
  updatedAt: new Date().toISOString(),
2371
2385
  }
2372
2386
  : record),
@@ -2922,7 +2936,7 @@ export function registerRunNotebookJob(engine) {
2922
2936
  }
2923
2937
  const now = new Date().toISOString();
2924
2938
  const run = {
2925
- id: runId,
2939
+ id: uuidv7(),
2926
2940
  notebookId: input.notebookId,
2927
2941
  status: errors.length === 0 ? "completed" : "failed",
2928
2942
  startedAt: now,
@@ -2939,11 +2953,16 @@ export function registerRunNotebookJob(engine) {
2939
2953
  runId,
2940
2954
  outputs,
2941
2955
  ...(errors.length > 0 ? { error: errors.join("\n") } : {}),
2956
+ }, {
2957
+ recordedRunId: run.id,
2958
+ status: run.status,
2959
+ completedAt: now,
2960
+ ...(run.error ? { error: run.error } : {}),
2942
2961
  });
2943
2962
  }
2944
2963
  if (errors.length > 0)
2945
2964
  throw new Error(errors.join("\n"));
2946
- return run;
2965
+ return { ...run, id: runId, runId, recordedRunId: run.id };
2947
2966
  };
2948
2967
  registerJobHandler("run_notebook", async (payload) => {
2949
2968
  const input = payload;
@@ -3,6 +3,7 @@ import { requireApiKey } from "../../api-key.js";
3
3
  import { uploadAssetToFal } from "../../jobs/_fal-upload.js";
4
4
  import { describeFalError } from "../../jobs/_fal-shared.js";
5
5
  import { withProviderHandoff } from "../../jobs/_lease.js";
6
+ import { generationCostHints } from "../cost.js";
6
7
  import { providerEndpoints } from "../endpoints.js";
7
8
  import { writePendingTask } from "./pending.js";
8
9
  import { MEDIA_TASK_TYPES } from "./types.js";
@@ -30,6 +31,12 @@ async function submitQueued(request, context, ownerId) {
30
31
  return submitted.request_id;
31
32
  }
32
33
  async function submit(request, context) {
34
+ const hints = generationCostHints(request, context.costHints);
35
+ if (Object.keys(hints).length > 0) {
36
+ const written = await context.engine.metadata.artifacts.write(context.assetId, "generation-cost-hints", hints);
37
+ if (!written.ok)
38
+ throw new Error(written.error.message);
39
+ }
33
40
  const taskId = await withProviderHandoff(context.engine, context.assetId, context.ctx, "generate", async (ownerId) => {
34
41
  const id = await submitQueued(request, context, ownerId);
35
42
  return { taskId: id, result: id };
@@ -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" } },
@@ -60,6 +67,8 @@ function h3MaxStyle(style) {
60
67
  }
61
68
  export const MODEL_ENTRIES = [
62
69
  { id: "kie:nano-banana-2-1", media: "image", operations: ["text_to_image", "edit_image"], dialect: "natural_numbered", defaults: { ...LIGHTNING_SCENE_OPTIONS } },
70
+ { id: "kie:flux-3/klein-text-to-image", media: "image", operations: ["text_to_image"], dialect: "natural", defaults: { resolution: "1K" } },
71
+ { id: "kie:flux-3/klein-edit-image", media: "image", operations: ["edit_image"], dialect: "natural", defaults: { resolution: "1K" } },
63
72
  { id: "kie:nano-banana-pro", media: "image", operations: ["text_to_image", "edit_image"], guide: "nano_banana", dialect: "natural" },
64
73
  { id: "kie:gpt-image-2-5-flare-text-to-image", media: "image", operations: ["text_to_image"], guide: "gpt_image", dialect: "natural", defaults: { resolution: "2K" } },
65
74
  { id: "kie:gpt-image-2-5-flare-image-to-image", media: "image", operations: ["edit_image"], guide: "gpt_image", dialect: "natural", defaults: { resolution: "2K" } },
@@ -108,6 +117,8 @@ export const MODEL_ENTRIES = [
108
117
  { id: "fal:ideogram/v4.5/edit", media: "image", operations: ["edit_image"], refs: { image: ["image_url", "reference_image_urls"] } },
109
118
  { id: "fal:blackforestlabs/flux-3/text-to-image", media: "image", operations: ["text_to_image"] },
110
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" },
111
122
  { id: "fal:fal-ai/flux-2-flex", media: "image", operations: ["text_to_image"] },
112
123
  { id: "fal:bria/guided-remove-background", media: "image", operations: ["edit_image"] },
113
124
  { id: "fal:bria/fibo-edit-1.5/expand", media: "image", operations: ["edit_image"] },
@@ -115,10 +126,10 @@ export const MODEL_ENTRIES = [
115
126
  {
116
127
  id: "kie:bytedance/seedance-2-5",
117
128
  media: "video",
118
- operations: ["text_to_video", "image_to_video", "reference_to_video"],
129
+ operations: ["text_to_video", "image_to_video", "reference_to_video", "edit_video"],
119
130
  guide: "seedance25",
120
131
  dialect: "seedance",
121
- modes: SEEDANCE_MODES,
132
+ modes: [...SEEDANCE_MODES, { id: "v2v", label: "Edit video", roles: ["video", "image", "audio"], requireAny: ["video"] }],
122
133
  defaults: { ...LIGHTNING_VIDEO_OPTIONS },
123
134
  frameOptions: { aspect_ratio: "adaptive" },
124
135
  },
@@ -170,6 +181,10 @@ export const MODEL_ENTRIES = [
170
181
  { id: "fal:alibaba/wan-3.0-prime/text-to-video", media: "video", operations: ["text_to_video"], hidden: true },
171
182
  { id: "fal:alibaba/wan-3.0-prime/image-to-video", media: "video", operations: ["image_to_video"], hidden: true },
172
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"] },
173
188
  { id: "fal:minimax/h3-max/insert-video", media: "video", operations: ["edit_video"], refs: { video: ["video_url", "reference_video_urls"] } },
174
189
  { id: "fal:minimax/h3-max/recast", media: "video", operations: ["edit_video"] },
175
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
  }
@@ -18,26 +83,34 @@ function durationHint(input) {
18
83
  ?? (numeric(input.music_length_ms) !== undefined ? numeric(input.music_length_ms) / 1000 : undefined);
19
84
  return seconds !== undefined && seconds > 0 ? { durationSeconds: seconds } : {};
20
85
  }
21
- function combinedHints(input, hints) {
86
+ export function generationCostHints(input, hints) {
22
87
  const combined = { ...durationHint(input), ...hints };
23
88
  return combined.durationSeconds === undefined && combined.inputDurationSeconds !== undefined
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: combinedHints(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);
@@ -1,3 +1,4 @@
1
+ import { isSeedanceEdit } from "./seedance-edit.js";
1
2
  import { referenceVideoDuration } from "../reference-video-duration.js";
2
3
  import { insertVideoTimingError, isInsertVideoModel } from "./insert-video-timing.js";
3
4
  import { resolveAudioRef, resolveImageRef, resolveVideoRef } from "../tools/_media-refs.js";
@@ -129,3 +130,15 @@ export async function checkInsertVideoTiming(engine, model, refs, options) {
129
130
  const duration = source ? await referenceVideoDuration(engine, [source]) : undefined;
130
131
  return insertVideoTimingError(options, duration);
131
132
  }
133
+ export async function checkSeedanceEditSource(engine, model, refs, mode) {
134
+ if (!isSeedanceEdit(model.id, mode))
135
+ return null;
136
+ if (refs.video?.length !== 1)
137
+ return "Seedance Edit video requires exactly one source video (4–30 seconds).";
138
+ const duration = await referenceVideoDuration(engine, refs.video);
139
+ if (duration === undefined)
140
+ return "Seedance Edit video requires a source with known duration. Import the video into the book and use its artifact reference (4–30 seconds).";
141
+ return duration < 4 || duration > 30
142
+ ? `Seedance Edit video source is ${duration} seconds; use a source between 4 and 30 seconds.`
143
+ : null;
144
+ }