Compare commits

..
Author SHA1 Message Date
Tanish Manku e9ea2a938c Scene Builder: T-pose in clip gaps + drag-preview card for animations
- Gaps show the default pose: a disabled three.js action leaves the skeleton
  frozen on its last frame, so evaluateAt now resets any character with no clip
  under the playhead to its bind (T) pose via Skeleton.pose() (skeletons cached
  per character). Fixes blank space after a clip holding the clip's last pose.
- Animation drags now show the shared DragGhost tilt card with the clip
  thumbnail: the drawer's native-DnD handlers drive dragItem/dragPosition/
  assetDraggingUnder and suppress the browser's default drag image, matching
  object/character pickup motion.
2026-07-09 00:59:24 -07:00
Tanish Manku dc5608613d webapp: stop dev API host override pointing at localhost:12345
The DEV block set the API host to window.location.origin (so /v1 calls hit the
Vite proxy → api.storyteller.ai) and then immediately called setDevelopment(),
which overrode it to http://localhost:12345 — a local backend that isn't
running. Every media-token resolution failed, so getMediaUrlByToken returned ''
and GLB fetches fell back to localhost, serving nothing. The inline comment
already described this line as disabled; it just wasn't commented out. Now it
is, so local dev resolves media through the proxy as intended.
2026-07-09 00:59:24 -07:00
Tanish Manku 1a90bd54c2 Scene Builder: one clip row per character + overlap guard
Collapse the per-character clips onto a single timeline row (playback is
sequential — one clip at a time), replacing stacked lanes. Stacking only earned
its keep for additive layering, which is out of scope.

- TimelineController: add/move route through resolveFreeStart (snap to nearest
  non-overlapping slot); resizeClipLane and resolveClipDuration clamp against
  nextStartAfter so a strip never overruns the following clip. A character's
  strips stay a non-overlapping sequence, so evaluateAt has at most one active
  clip at any playhead.
- TimelineClipRow: renders all of a character's strips on one shared row
  (shown even when empty, as a drop hint); per-strip move/trim/loop and an
  inline x to remove.
- TimelineEditor: one TimelineClipRow per character instead of one per lane.
2026-07-08 00:04:13 -07:00
Tanish Manku 2920a189f0 Scene Builder: Mixamo clip-lane timeline UI + real clip length (phase C)
Stacked clip-strip rendering on the timeline with move/trim/loop/delete, drag-
and-drop from the Animations drawer onto a character's lane, and backfill of the
clip's real length into the strip once its GLB loads.

- TimelineClipRow: one stacked strip per clip lane under its character's
  keyframe row; body drags to move, right edge trims length, loop chip toggles
  repeat, trash removes. Clip edits ride the timeline Save/Cancel session (no
  per-op undo, matching keyframes).
- TimelineEditor: each character row is a drop target for clip drags; drop time
  is read from the ruler rect. Non-character rows ignore clip drags.
- AnimationsDrawer clips are now draggable (ANIMATION_CLIP_MIME) as well as
  click-to-add.
- ClipStrip.autoDuration: a fresh drop seeds a placeholder width, then
  CharacterAnimationManager.resolveClipDuration adopts the loaded clip's true
  length (re-clamping start); a user trim clears the flag so reload never
  clobbers a hand-set duration.
- clipBindsToCharacter warns when a clip resolves 0 tracks on the character —
  the diagnostic for whether retargeting is needed.
2026-07-07 23:51:37 -07:00
Tanish Manku 7f5e82cbd9 Scene Builder: Mixamo clip playback engine + drawer (phase B, spike)
Deterministic, playhead-driven skeletal-animation playback for character clip
lanes, plus a minimal click-to-add drawer so retargeting/binding can be verified
in the viewport before the full drag-drop / stacked-strip UI is built.

- CharacterAnimationManager: one AnimationMixer per character; each clip lane is
  an AnimationAction posed at an absolute time from the playhead + mixer.update(0)
  (scrub / play / record all pose identically). syncClipLanes() reconciles the
  runtime on every lane change; a load token guards superseded async clip loads.
- Scene.loadRawGlb(media_id): raw GLTF (scene + animations) with no scene-add /
  placeholder — sources clips for binding. Clips bind by node name (shared
  mixamorig:* naming); SkeletonUtils.retargetClip is the documented fallback.
- TimelineController: getPlayhead(); evaluate() poses characters; sync on
  add/move/remove clip lane + load/cancel/clear.
- actions: addClipToCharacter / removeClipLane.
- comps/AnimationsDrawer: right-docked, gated on a selected character; click a
  curated clip to drop it as a lane at the playhead.

Compiles clean (tsc exit 0); playback is NOT yet runtime-verified.
2026-07-07 15:42:24 -07:00
Tanish Manku 5c2fbbc9f3 Scene Builder: animation clip-lane data model (Mixamo drawer, phase A)
Foundation for placing Mixamo skeletal-animation clips on the timeline as
stacked lanes under character objects, alongside the existing keyframe tracks.

- ClipStrip / ClipLane types; TimelineData gains a `clipLanes` array (only the
  clip reference + placement is serialized — the AnimationClip is resolved and
  retargeted from sourceMediaId at runtime).
- TimelineController: addClipLane / moveClipLane / removeClipLane / clipLanesFor,
  create()/loadTimeline() init + backfill clipLanes, emitChanged carries them.
- TimelineChangedEvent → store mirror (timelineClipLanes) via EngineStoreBridge.
- Persistence rides the existing timeline save/load (getTimeline/loadTimeline).
2026-07-07 15:28:50 -07:00
Tanish Manku 11c7aa979d Scene Builder fixes: record-mode camera lock, Blender default framing, auto-keyframe
- Record mode is now fully immutable: gate MouseControls mouse-look + focus()
  on the viewport lock (new MouseControlsDeps.isViewportLocked → cameraController
  .locked), matching the existing FreeCam/tick gates.
- Default main (viewport) camera pulled back + elevated to a Blender-style 3/4
  view so a blank scene shows the full render-camera frustum.
- Auto-keyframe: mutating an already-keyframed object (gizmo drag-end or panel
  commit) replaces/creates a keyframe at the playhead via
  TimelineController.autoKeyIfTracked.
- loadTimeline backfills fps/duration/tracks defensively (legacy save safety).
2026-07-07 15:23:50 -07:00
bflatastic 26aa3d3839 3D scene editor: exact camera framing, timeline UX, outliner + chrome redesign
Camera framing
- Replace the full-canvas Letterbox with CameraFrame: an inset, aspect-correct
  frame (dimmed surround, rule-of-thirds, focal label) shown only in camera
  view and record mode — the free viewport no longer shows mattes
- The frame shows the EXACT render framing: CameraController.setCameraFrameRect
  offsets the viewport projection via camera.setViewOffset; snapshots and the
  timeline recorder clear/reapply the offset so captures stay full-bleed
- recordTimeline now encodes at the render camera's resolution/aspect with the
  grid hidden (was grabbing the raw viewport canvas)
- Frame avoids the bottom UI; expanded-timeline height is measured live

Aspect ratio
- Default is now 16:9 via DEFAULT_CAMERA_ASPECT_RATIO (store, controller,
  scene reset, newScene); removed the model cold-sync that forced Square
- New top-right aspect popover (AspectRatioMenu rewritten on PopoverMenu with
  proportional AspectRatioIcon, now exported from ui-promptbox)

Timeline
- Expanded editor shows every scene object as a track row (no selection
  needed), capped at ~3.5 rows then scrollable (scrollbar hidden so lanes
  stay width-aligned with the ruler)
- Restyled per design: dark lanes, keyframe diamonds + connecting line,
  playhead diamond handle, select-none everywhere
- Scrubbing: drag the ruler to scrub smoothly, following the pointer 1:1;
  times quantize to the timeline's frame grid (30fps) — transport readout
  shows frame timecode (m:ss:ff); keyframe drags quantize the same way
- Fixed playhead/keyframe misalignment (scrollbar shrank lanes vs ruler;
  label indent must be padding, not margin, to keep the w-32 column)
- Delete/Backspace removes the selected keyframe (capture-phase, so the
  engine's delete-scene-object keybind doesn't fire); clicking away
  deselects it
- Motion (easing) popover opens from the curve chip BETWEEN keyframes (new
  timelineEasingKeyframeId store field), centered above the chip in lane
  coordinates, backdrop-blurred, dismissed on outside click; selecting a
  keyframe no longer opens it
- Camera-track playback/scrubbing is now visible in camera view: the
  timeline mirrors the animated ::CAM:: pose onto the viewport camera

Record mode
- Viewport camera fully locked: mouse-look blocked via isMovable(), right-drag
  pan and wheel zoom gated in useFreeCam (build mode unaffected)

Chrome
- Outliner restyled as the "Scene" panel (+ Add via shared openAssetModal,
  two-line rows with friendly kind labels, blue selected card), open by
  default, vertically centered, fixed 45vh height independent of aspect ratio
- Build/Record pill fully rounded (new TabSelector listClassName prop)
- CameraStatusPill moved to the top-right camera cluster, height-matched
  with the aspect popover
- ExitCameraView is a regular labeled button docked right of the timeline bar
2026-07-07 23:15:54 +07:00
Tanish Manku 06315490b7 Scene Builder launch re-scope: drop promptbox, lightbox handoff, editable timeline duration
- Remove SceneBuilderPromptBox (MCP not ready). The animation timeline is the
  sole build-mode bottom UI: collapsed bar by default, expanded editor via
  chevron; an empty timeline is auto-created on init; auto-expands on load when
  the scene has keyframes or an imported THREE animation.
- Editable max timeline duration: TimelineController.setDuration (clamp 1-60s,
  covers 5-30) + click-to-edit DurationLabel in the bar/ruler.
- Capture/Record output → review modal (CompletionModal) that appears FIRST
  with a local preview (offline-safe), then uploads (images auto, videos manual)
  with status; on success opens the app Lightbox on the token (all destinations
  for the kind), on failure shows Retry.
- Adapter: add openMediaLightbox(token, kind); keep uploadMedia; remove
  openImageInEditor/openVideoInEditor (Lightbox owns destination routing). Host
  renders <Lightbox> and resolves cdnUrl via MediaFilesApi.

Compile-verified (tsc -b clean for pagescene + changed webapp files).
2026-07-06 23:54:47 -07:00
Tanish Manku c15d8bd1ec Scene Builder checkpoint: camera view fixes, record/capture + completion modal, save/load + undo persistence
Builds on the prior Scene Builder commit. Compile-verified (tsc -b clean for
pagescene + changed webapp files); see frontend/libs/components/pagescene/CLAUDE.md.

- Camera view: fixed record→build engine-teardown wipe (PreviewEngineCamera /
  cam-view canvas must stay mounted — hide via CSS, never unmount); Esc +
  viewport double-click exit; double-click frustum to enter; robust enter/exit
  pose; frustum pick-proxy hitbox; CameraStatusPill (Viewport vs render cam).
- Build promptbox: dedicated SceneBuilderPromptBox (MCP stub) replaced
  PromptBox3D; buildMode/toggle removed.
- Record mode: Capture (still) + Record (timeline→mp4 via mediabunny),
  RenderOverlay progress, producedArtifact cache, CompletionModal
  (preview/playback + Delete/Upload/Edit) + adapter handoff to 2D/create-video/
  video-editor (lazy, data-URL image handoff; best-effort uploads).
- Persistence: timeline + render-camera transform in scene JSON (save/load +
  unmount cache); ::CAM:: recreated on load after cameras restore.
- Undo: timeline transactional (SaveTimelineAction); camera moves via TransformAction.
2026-07-06 23:26:18 -07:00
Tanish Manku 40ec291395 Scene Builder: mode pill, prompted mode, timeline, camera split, record mode
Adds the Scene Builder feature set to the pagescene 3D editor (+ the shared
ui-promptbox). All UI-level; compile-verified only (tsc -b), not yet runtime
tested — see frontend/libs/components/pagescene/CLAUDE.md for the full spec and
per-slice "unverified" list.

- Build/Record mode pill (SceneModePill, TabSelector) + store `sceneMode`.
- Build sub-modes: Manual (current) vs Prompted. Prompted prompt bar shows a
  textbox + add-animation-timeline + prompt history + Update (Update is a stub
  pending the MCP scene-descriptor backend). Toggle via ButtonIconSelect.
- Animation timeline (in-memory): TimelineController + keyframe model +
  cubic-bezier interpolation ticked in the render loop; collapsed bar +
  expanded multi-track editor + Motion easing popover (draggable bezier);
  Save is an undoable action, Cancel reverts.
- Camera split (single camera): render camera is a selectable wireframe-frustum
  placeholder ("::CAM::") in the scene; look-through camera view via
  double-click / outliner view-from-camera icon; ExitCameraView button.
- Record mode: read-only playback through the locked render camera; build
  chrome hidden; Capture (still) works, Record (video) is a stub.
2026-07-03 05:24:48 -07:00
Brandon Thomas 3a2e3b30ec debug logs #2 (#1723) 2026-07-02 19:36:48 -04:00
Brandon Thomas 82c0a7a4d9 debug logs: new field, new queries, new APIs, new dashbord UX (#1722) 2026-07-02 16:43:00 -04:00
Brandon Thomas cbd3710f4f seedance text prompt max length (#1721) 2026-07-02 11:26:02 -04:00
BFlat 044c6d8a88 Model list cleanup: shared omni-gen hooks, dead code removal (#1719)
### New shared lib: `@storyteller/omni-gen`
- The `omni-gen-hooks.ts` file was copy-pasted between `artcraft-webapp`
and
`artcraft-website` and had drifted (website copy was missing outage
toasts,
carried a stale hardcoded display-name map, and had weaker icon
resolution).
- Extracted the newer (webapp) version into a new lib
`frontend/libs/omni-gen`
  (generated via `nx g @nx/react:library`): singleton session cache,
`useOmniGenImageModels()` / `useOmniGenVideoModels()`, outage message
constants.
- App-local toasts are injected via `setOmniGenErrorNotifier()`, called
once in
each app's `main.tsx`, since the hooks are singleton-cache based and
each app
  has its own toast component.

### Consolidated model metadata into `@storyteller/model-list`
- `getModelDescription()` / `getModelInfo()` (tagline + info-blurb
fallbacks)
  moved to `ModelDescriptions.ts`; API values still always win.
- `getModelDisplayName()` gained an optional `fullName` param (API name
wins);
  the app-local wrapper is gone.
- Both apps' local `getModelCreatorIconPath()` replaced with the
existing,
stronger `getCreatorIconPathForModelId()` (checks `ALL_MODELS_LIST`
first,
then prefix match). The loader's duplicate `CREATOR_BY_PREFIX` map now
reuses
  the same shared `MODEL_ID_PREFIX_CREATORS` table.

### Dead code removed
- `modelReconciliation.ts`: deprecated `reconcile*()` helpers (never
called) and
the empty, retired `BACKEND_TO_TAURI_*` alias maps.
`buildModelsFromListing()`
dropped its alias-map indirection accordingly (backend ids == tauriIds
since
the 2026-07 id migration). `modelCreatorFromBackend()` moved inline into
the
  loader, its only consumer.
- Six unused static `*_PAGE_MODEL_LIST` constants in
`model-selectors-for-pages.tsx`
(superseded by the `use*PageModelList` hooks in #1710). The two
`IMAGE_TO_3D_*`
  constants stay: 3D models are not served by the omni backend.
- Untracked the accidentally committed
`apps/artcraft/tsconfig.tsbuildinfo` and
  gitignored `*.tsbuildinfo`.

### Website-visible improvements (from adopting the newer hooks)
- Model outage toasts now appear on the website (previously
webapp-only).
- Current display names from the canonical map (previously-unmapped ids
like
  `gpt_image_2` and Midjourney models rendered as raw ids).
- Model taglines/descriptions now render in the website model picker.
- Midjourney and SwitchX creator icons now resolve.
2026-07-02 15:06:15 +07:00
Brandon Thomas 353c49c55f bump artcraft to 0.36 (#1718) 2026-07-02 03:45:07 -04:00
Brandon Thomas 98ea5cdb62 shink assets that are way too large (#1717) 2026-07-02 03:42:41 -04:00
Brandon Thomas 8fdc5100af vidu (#1716) 2026-07-02 03:12:15 -04:00
Brandon Thomas b782cb0c67 veo (#1715) 2026-07-02 02:18:16 -04:00
Brandon Thomas f01e81b66a artcraft 0.35 (#1714) 2026-07-02 02:02:16 -04:00
Brandon Thomas 3c0d1415e5 update pathing 2026-07-02 01:30:07 -04:00
Brandon Thomas 5e4b7f271d artcraft 0.34 (#1713) 2026-07-02 01:16:31 -04:00
Brandon Thomas 2371dfd49e move assets out of build to decrease binary size (#1712) 2026-07-02 01:15:30 -04:00
Brandon Thomas 2f20e7b3a3 fix midjourney (#1711) 2026-07-02 00:06:27 -04:00
412 changed files with 18449 additions and 3847 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_log_level = ?,\n maybe_creator_user_token = ?,\n maybe_ip_address = ?,\n maybe_url = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "32a8d1cfec89ba2056463a33ddc501a8a99283c7c00982a7fa79f7ff382c9b22"
}
@@ -1,80 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n d.id as `id: u64`,\n d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,\n d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n d.maybe_ip_address,\n d.maybe_url,\n d.message,\n d.created_at,\n u.display_name as `maybe_user_display_name?`,\n u.username as `maybe_user_username?`,\n u.email_gravatar_hash as `maybe_user_gravatar_hash?`\nFROM debug_logs d\nLEFT OUTER JOIN users u\n ON u.token = d.maybe_creator_user_token\nWHERE d.maybe_creator_user_token = ?\n AND d.id < ?\nORDER BY d.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 7,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 8,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_user_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "maybe_user_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 11,
"name": "maybe_user_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "d1ade7fcfd3efac1cd3163c392110327bb73edb74fa0d9693fee5feddb8a3223"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n d.id as `id: u64`,\n d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,\n d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n d.maybe_ip_address,\n d.maybe_url,\n d.message,\n d.created_at,\n u.display_name as `maybe_user_display_name?`,\n u.username as `maybe_user_username?`,\n u.email_gravatar_hash as `maybe_user_gravatar_hash?`\nFROM debug_logs d\nLEFT OUTER JOIN users u\n ON u.token = d.maybe_creator_user_token\nWHERE d.event_token = ?\nORDER BY d.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 7,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 8,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_user_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 10,
"name": "maybe_user_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 11,
"name": "maybe_user_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "e9a149ffc963a59019fdd69bd53128fad946856d432108bf9b923dfeaf2442c5"
}
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_log_level VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP INDEX index_maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD INDEX index_maybe_log_level (maybe_log_level);
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_ip_address;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_ip_address VARCHAR(40) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_url;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_url VARCHAR(255) DEFAULT NULL;
@@ -13,9 +13,18 @@ CREATE TABLE debug_logs (
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The severity level of the log entry (info, warn, error, debug, trace).
maybe_log_level VARCHAR(16) DEFAULT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The client IP address of the request (if any). Wide enough for IPv6.
maybe_ip_address VARCHAR(40) DEFAULT NULL,
-- The request URL (if any). Truncated to 255 characters on insert.
maybe_url VARCHAR(255) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
@@ -25,6 +34,7 @@ CREATE TABLE debug_logs (
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_log_level (maybe_log_level),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
@@ -0,0 +1,36 @@
use chrono::{DateTime, Utc};
use serde_derive::Serialize;
use utoipa::ToSchema;
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
/// A single debug log row.
/// Shared by the moderation debug-log list endpoints.
#[derive(Serialize, ToSchema)]
pub struct ModerationDebugLogEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
/// The client IP address of the request (if recorded).
pub maybe_ip_address: Option<String>,
/// The request URL (if recorded; truncated to 255 chars).
pub maybe_url: Option<String>,
pub message: String,
pub created_at: DateTime<Utc>,
/// The creator user, joined in when the row has a user token.
pub maybe_user: Option<ModerationDebugLogUser>,
}
/// The creator user of a debug log row.
#[derive(Serialize, ToSchema)]
pub struct ModerationDebugLogUser {
pub user_token: UserToken,
pub display_name: String,
pub username: String,
pub gravatar_hash: String,
}
@@ -0,0 +1,4 @@
pub mod debug_log_entry;
pub mod moderation_list_all_debug_logs;
pub mod moderation_list_debug_logs_for_token;
pub mod moderation_list_debug_logs_for_user;
@@ -0,0 +1,76 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::error::enum_error::EnumError;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogUser;
pub const MODERATION_LIST_ALL_DEBUG_LOGS_PATH: &str = "/v1/moderation/debug_logs/list_all";
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListAllDebugLogsQueryParams {
/// Optional comma-separated list of severity levels to include,
/// eg. `severities=error,warn`. Omit to include all rows.
pub severities: Option<String>,
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
impl ModerationListAllDebugLogsQueryParams {
/// Parse the comma-separated `severities` parameter.
/// Returns `None` when the parameter is absent or empty.
pub fn parsed_severities(&self) -> Result<Option<Vec<DebugLogLevel>>, EnumError> {
let raw = match self.severities.as_deref() {
Some(value) if !value.trim().is_empty() => value,
_ => return Ok(None),
};
let levels = raw
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(DebugLogLevel::from_str)
.collect::<Result<Vec<DebugLogLevel>, EnumError>>()?;
Ok(if levels.is_empty() { None } else { Some(levels) })
}
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListAllDebugLogsSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationListAllDebugLogsEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
/// A debug log row with the creator user joined in (when one exists).
#[derive(Serialize, ToSchema)]
pub struct ModerationListAllDebugLogsEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
/// The client IP address of the request (if recorded).
pub maybe_ip_address: Option<String>,
/// The request URL (if recorded; truncated to 255 chars).
pub maybe_url: Option<String>,
pub message: String,
pub created_at: DateTime<Utc>,
pub maybe_user: Option<ModerationDebugLogUser>,
}
@@ -0,0 +1,30 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_TOKEN_PATH: &str = "/v1/moderation/debug_logs/list/{token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenPathInfo {
pub token: DebugLogEventToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForTokenQueryParams {
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenSuccessResponse {
pub success: bool,
pub debug_logs: Vec<ModerationDebugLogEntry>,
}
@@ -0,0 +1,37 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_USER_PATH: &str = "/v1/moderation/debug_logs/user_list/{user_token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForUserPathInfo {
pub user_token: UserToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForUserQueryParams {
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForUserSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationDebugLogEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
@@ -1,4 +1,5 @@
pub mod alerts;
pub mod debug_logs;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -138,6 +138,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -146,13 +147,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// 3 → 4, anything > 4 → 4
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 3 → 2, anything > 4 → 4
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -379,9 +378,10 @@ mod tests {
}
#[test]
fn one_two_four_map_directly() {
fn one_two_three_four_map_directly() {
assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One);
assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two);
assert_eq!(batch_for(Some(3)), KinoviMidjourneyBatchCount::Three);
assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four);
}
@@ -394,43 +394,57 @@ mod tests {
));
}
/// Kinovi now supports batches of 3, so 3 is a first-class value and the
/// mitigation strategy no longer applies to it.
#[test]
fn three_errors_under_strict() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
fn three_maps_directly_regardless_of_strategy() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Three,
"strategy={:?}", strategy,
);
}
}
/// Counts above 4 are still unsupported: strict errors out, and both
/// pay-more/pay-less strategies clamp to the maximum supported batch (4).
#[test]
fn five_errors_under_strict_and_clamps_to_four_otherwise() {
let strict = GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(matches!(
build_kinovi_midjourney_7(builder),
build_kinovi_midjourney_7(strict),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })),
));
}
#[test]
fn three_upgrades_to_four_with_pay_more() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
);
}
#[test]
fn three_downgrades_to_two_with_pay_less() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Two,
);
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
"strategy={:?}", strategy,
);
}
}
}
@@ -126,6 +126,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -134,11 +135,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -212,6 +213,16 @@ mod tests {
));
}
#[test]
fn batch_count_three_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() };
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three);
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
@@ -126,6 +126,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -134,11 +135,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -201,6 +202,16 @@ mod tests {
));
}
#[test]
fn batch_count_three_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() };
let req = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three);
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
@@ -0,0 +1,2 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
@@ -0,0 +1,288 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::veo_3p1::raw_request::{
Veo3p1ExtendVideoInput, Veo3p1ExtendVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ExtendVideoRequest {
/// Text prompt describing how the video should be extended.
pub prompt: String,
/// URL of the video to extend (720p/1080p, 16:9 or 9:16).
pub video_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1ExtendVideoAspectRatio>,
/// Extension duration. fal's default is `7s` when `None`.
pub duration: Option<Veo3p1ExtendVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ExtendVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ExtendVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ExtendVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
/// Extension duration. fal documents only the `7s` default for extend-video
/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 family
/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects
/// a value, trim this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoDuration {
FourSeconds,
SixSeconds,
/// fal's documented default for extend-video.
SevenSeconds,
EightSeconds,
}
impl Veo3p1ExtendVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1ExtendVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ExtendVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ExtendVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/extend-video";
type RawRequest = Veo3p1ExtendVideoInput;
type RawResponse = Veo3p1ExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ExtendVideoRequest {
prompt: "the scene continues naturally, keeping the same motion and style".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ExtendVideoRequest {
prompt: "the camera pulls back to reveal more of the landscape".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: Some(Veo3p1ExtendVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::TenEightyP),
negative_prompt: Some("nope".to_string()),
generate_audio: Some(true),
seed: Some(3),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1ExtendVideoSafetyTolerance::Level4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.video_url, "https://example.com/in.mp4");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("7s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(3));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("4"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3p1ExtendVideoDuration::FourSeconds, "4s", 4),
(Veo3p1ExtendVideoDuration::SixSeconds, "6s", 6),
(Veo3p1ExtendVideoDuration::SevenSeconds, "7s", 7),
(Veo3p1ExtendVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ExtendVideoRequest::ENDPOINT,
"fal-ai/veo3.1/extend-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,109 @@
use crate::requests::api::video::extend::veo_3p1::api::{
Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/extend-video):
// $0.20/sec (audio off), $0.40/sec (audio on).
//
// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the
// family's SD rate — `four_k` is fixed to `false`.
impl FalRequestCostCalculator for Veo3p1ExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 7s, audio = on. Resolution does not
// affect price here (720p and 1080p bill identically).
let duration_secs = self.duration
.unwrap_or(Veo3p1ExtendVideoDuration::SevenSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(false, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoResolution;
fn make_request(
duration: Option<Veo3p1ExtendVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3p1ExtendVideoRequest {
Veo3p1ExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ExtendVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(false), 140),
(Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(true), 280),
(Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(true), 320),
// Defaults: 7s, audio on
(None, None, 280),
(None, Some(false), 140),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
/// fal: "a 5s video with audio on will cost $2" → 5s × $0.40 = 200¢.
#[test]
fn matches_documented_5s_audio_on_example() {
let rate = veo_3p1_rate_tenth_cents_per_sec(false, true);
assert_eq!(veo_3p1_cost_cents(rate, 5), 200);
}
#[test]
fn default_duration_is_seven_seconds() {
// duration=None, audio=on → 7s @ $0.40 = 280¢
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 280);
}
/// Resolution (720p vs 1080p) must not change the bill for extend.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio)
.calculate_cost_in_cents();
for res in [Veo3p1ExtendVideoResolution::SevenTwentyP, Veo3p1ExtendVideoResolution::TenEightyP] {
let cost = Veo3p1ExtendVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/extend-video`.
/// Extends an existing video by generating a continuation.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ExtendVideoInput {
pub prompt: String,
/// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16.
pub video_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string). fal default: "7s". fal documents no
/// explicit enum for this endpoint (only the default), so it is sent as a
/// free-form string.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Output resolution. fal default: "720p" (the docs describe 720p/1080p
/// source requirements and list no 4k tier for extend).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ExtendVideoOutput {
pub video: Veo3p1ExtendVideoVideoFile,
}
@@ -0,0 +1,288 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::veo_3p1_fast::raw_request::{
Veo3p1FastExtendVideoInput, Veo3p1FastExtendVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastExtendVideoRequest {
/// Text prompt describing how the video should be extended.
pub prompt: String,
/// URL of the video to extend (720p/1080p, 16:9 or 9:16).
pub video_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastExtendVideoAspectRatio>,
/// Extension duration. fal's default is `7s` when `None`.
pub duration: Option<Veo3p1FastExtendVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastExtendVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastExtendVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastExtendVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
/// Extension duration. fal documents only the `7s` default for extend-video
/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 Fast family
/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects
/// a value, trim this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoDuration {
FourSeconds,
SixSeconds,
/// fal's documented default for extend-video.
SevenSeconds,
EightSeconds,
}
impl Veo3p1FastExtendVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1FastExtendVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastExtendVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastExtendVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/extend-video";
type RawRequest = Veo3p1FastExtendVideoInput;
type RawResponse = Veo3p1FastExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastExtendVideoRequest {
prompt: "the scene continues naturally, keeping the same motion and style".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastExtendVideoRequest {
prompt: "the camera pulls back to reveal more of the landscape".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: Some(Veo3p1FastExtendVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::TenEightyP),
negative_prompt: Some("nope".to_string()),
generate_audio: Some(true),
seed: Some(3),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1FastExtendVideoSafetyTolerance::Level4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.video_url, "https://example.com/in.mp4");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("7s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(3));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("4"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3p1FastExtendVideoDuration::FourSeconds, "4s", 4),
(Veo3p1FastExtendVideoDuration::SixSeconds, "6s", 6),
(Veo3p1FastExtendVideoDuration::SevenSeconds, "7s", 7),
(Veo3p1FastExtendVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastExtendVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/extend-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,102 @@
use crate::requests::api::video::extend::veo_3p1_fast::api::{
Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/fast/extend-video):
// $0.10/sec (audio off), $0.15/sec (audio on).
//
// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the
// family's SD rate — `four_k` is fixed to `false`.
impl FalRequestCostCalculator for Veo3p1FastExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 7s, audio = on. Resolution does not
// affect price here (720p and 1080p bill identically).
let duration_secs = self.duration
.unwrap_or(Veo3p1FastExtendVideoDuration::SevenSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(false, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoResolution;
fn make_request(
duration: Option<Veo3p1FastExtendVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3p1FastExtendVideoRequest {
Veo3p1FastExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastExtendVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(false), 40),
(Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(true), 60),
(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(false), 70),
(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(true), 105),
(Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(false), 80),
(Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(true), 120),
// Defaults: 7s, audio on
(None, None, 105),
(None, Some(false), 70),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn default_duration_is_seven_seconds() {
// duration=None, audio=on → 7s @ $0.15 = 105¢
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 105);
}
/// Resolution (720p vs 1080p) must not change the bill for extend.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio)
.calculate_cost_in_cents();
for res in [Veo3p1FastExtendVideoResolution::SevenTwentyP, Veo3p1FastExtendVideoResolution::TenEightyP] {
let cost = Veo3p1FastExtendVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/extend-video`.
/// Extends an existing video by generating a continuation.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastExtendVideoInput {
pub prompt: String,
/// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16.
pub video_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string). fal default: "7s". fal documents no
/// explicit enum for this endpoint (only the default), so it is sent as a
/// free-form string.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Output resolution. fal default: "720p" (the docs describe 720p/1080p
/// source requirements and list no 4k tier for extend).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastExtendVideoOutput {
pub video: Veo3p1FastExtendVideoVideoFile,
}
@@ -2,3 +2,11 @@ pub mod kling_1p6_pro_image_to_video;
pub mod kling_2p5_turbo_pro_image_to_video;
pub mod kling_2p6_pro_image_to_video;
pub mod kling_3p0_standard_image_to_video;
pub mod veo_2;
pub mod veo_3;
pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -0,0 +1,158 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_2::raw_request::{
Veo2ImageToVideoInput, Veo2ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo2ImageToVideoRequest {
/// Text prompt describing how the image should be animated.
pub prompt: String,
/// URL of the input image to animate (720p+, 16:9 or 9:16).
pub image_url: String,
/// Video duration. fal's default is `5s` when `None`.
pub duration: Option<Veo2ImageToVideoDuration>,
}
/// Veo 2 supports 5–8 second durations (default 5s).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2ImageToVideoDuration {
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
}
impl Veo2ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FiveSeconds => "5s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
impl FalEndpoint for Veo2ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo2/image-to-video";
type RawRequest = Veo2ImageToVideoInput;
type RawResponse = Veo2ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration.map(|d| d.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::FiveSeconds),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::FiveSeconds),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo2ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
duration: Some(Veo2ImageToVideoDuration::EightSeconds),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.duration.as_deref(), Some("8s"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo2ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
duration: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo2ImageToVideoDuration::FiveSeconds, "5s", 5),
(Veo2ImageToVideoDuration::SixSeconds, "6s", 6),
(Veo2ImageToVideoDuration::SevenSeconds, "7s", 7),
(Veo2ImageToVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo2ImageToVideoRequest::ENDPOINT, "fal-ai/veo2/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,62 @@
use crate::requests::api::video::image::veo_2::api::{Veo2ImageToVideoDuration, Veo2ImageToVideoRequest};
use crate::requests::api::video::text::veo_2::cost::veo_2_cost_cents;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 2 pricing (see the text module for the
// canonical formula): $2.50 base for 5s, +$0.50 per additional second.
impl FalRequestCostCalculator for Veo2ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = self.duration
.unwrap_or(Veo2ImageToVideoDuration::FiveSeconds)
.to_seconds();
veo_2_cost_cents(duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(duration: Option<Veo2ImageToVideoDuration>) -> Veo2ImageToVideoRequest {
Veo2ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
duration,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents): 250 base + 50 per second over 5s.
const COST_TABLE: &[(Option<Veo2ImageToVideoDuration>, u64)] = &[
(Some(Veo2ImageToVideoDuration::FiveSeconds), 250),
(Some(Veo2ImageToVideoDuration::SixSeconds), 300),
(Some(Veo2ImageToVideoDuration::SevenSeconds), 350),
(Some(Veo2ImageToVideoDuration::EightSeconds), 400),
// Default: duration=None → 5s
(None, 250),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
assert_eq!(
make_request(duration).calculate_cost_in_cents(),
expected,
"duration={duration:?}",
);
}
}
/// Image-to-video must bill identically to text-to-video at every duration.
#[test]
fn matches_text_to_video_pricing() {
for (secs, expected) in [(5, 250), (6, 300), (7, 350), (8, 400)] {
assert_eq!(veo_2_cost_cents(secs), expected, "secs={secs}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo2/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo2/image-to-video/api>
///
/// NB: Veo 2 image-to-video has a minimal schema — only `prompt`, `image_url`,
/// and `duration`. There is no aspect_ratio, negative_prompt, seed, auto_fix,
/// enhance_prompt, resolution, or generate_audio (aspect ratio is taken from
/// the input image).
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2ImageToVideoInput {
pub prompt: String,
/// URL of the input image to animate (720p+, 16:9 or 9:16).
pub image_url: String,
/// Duration in seconds (as a string).
/// Possible values: "5s", "6s", "7s", "8s". fal default: "5s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2ImageToVideoOutput {
pub video: Veo2ImageToVideoVideoFile,
}
@@ -0,0 +1,276 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3::raw_request::{
Veo3ImageToVideoInput, Veo3ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3ImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3ImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3ImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3ImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3ImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3ImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3ImageToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3ImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3/image-to-video";
type RawRequest = Veo3ImageToVideoInput;
type RawResponse = Veo3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3ImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3ImageToVideoAspectRatio::Auto),
duration: Some(Veo3ImageToVideoDuration::SixSeconds),
resolution: Some(Veo3ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3ImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3ImageToVideoAspectRatio::Auto, "auto"),
(Veo3ImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3ImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo3ImageToVideoRequest::ENDPOINT, "fal-ai/veo3/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,90 @@
use crate::requests::api::video::image::veo_3::api::{
Veo3ImageToVideoDuration, Veo3ImageToVideoRequest,
};
use crate::requests::api::video::text::veo_3::cost::{veo_3_cost_cents, veo_3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3 (non-fast) per-second pricing (see the text
// module for the canonical rate table): $0.20/sec (audio off), $0.40/sec
// (audio on). Flat — resolution does not affect the price.
impl FalRequestCostCalculator for Veo3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3ImageToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_rate_tenth_cents_per_sec(audio_on);
veo_3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3::api::Veo3ImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3ImageToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3ImageToVideoRequest {
Veo3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution: None,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3ImageToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3ImageToVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3ImageToVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3ImageToVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true), 320),
// Defaults: 8s, audio on
(None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(Veo3ImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3ImageToVideoAspectRatio::Auto,
Veo3ImageToVideoAspectRatio::SixteenByNine,
Veo3ImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3ImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3ImageToVideoOutput {
pub video: Veo3ImageToVideoVideoFile,
}
@@ -0,0 +1,279 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3_fast::raw_request::{
Veo3FastImageToVideoInput, Veo3FastImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3FastImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3FastImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3FastImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3FastImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3FastImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3FastImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3FastImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 Fast exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3FastImageToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3FastImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3FastImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3/fast/image-to-video";
type RawRequest = Veo3FastImageToVideoInput;
type RawResponse = Veo3FastImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3FastImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3FastImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::Auto),
duration: Some(Veo3FastImageToVideoDuration::SixSeconds),
resolution: Some(Veo3FastImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3FastImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3FastImageToVideoAspectRatio::Auto, "auto"),
(Veo3FastImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3FastImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3FastImageToVideoRequest::ENDPOINT,
"fal-ai/veo3/fast/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,92 @@
use crate::requests::api::video::image::veo_3_fast::api::{
Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest,
};
use crate::requests::api::video::text::veo_3_fast::cost::{
veo_3_fast_cost_cents, veo_3_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3 Fast per-second pricing (see the text module
// for the canonical rate table): $0.10/sec (audio off), $0.15/sec (audio on).
// Flat — resolution does not affect the price.
impl FalRequestCostCalculator for Veo3FastImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3FastImageToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_fast_rate_tenth_cents_per_sec(audio_on);
veo_3_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3FastImageToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3FastImageToVideoRequest {
Veo3FastImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution: None,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3FastImageToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3FastImageToVideoDuration::FourSeconds), Some(false), 40),
(Some(Veo3FastImageToVideoDuration::FourSeconds), Some(true), 60),
(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(false), 80),
(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true), 120),
// Defaults: 8s, audio on
(None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(Veo3FastImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3FastImageToVideoAspectRatio::Auto,
Veo3FastImageToVideoAspectRatio::SixteenByNine,
Veo3FastImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3/fast/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3/fast/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3FastImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3FastImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3FastImageToVideoOutput {
pub video: Veo3FastImageToVideoVideoFile,
}
@@ -0,0 +1,285 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1::raw_request::{
Veo3p1ImageToVideoInput, Veo3p1ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1ImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1ImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1ImageToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/image-to-video";
type RawRequest = Veo3p1ImageToVideoInput;
type RawResponse = Veo3p1ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1ImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1ImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1ImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1ImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1ImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::image::veo_3p1::api::{
Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest, Veo3p1ImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the
// text module for the canonical rate table):
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1ImageToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1ImageToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1ImageToVideoDuration>,
resolution: Option<Veo3p1ImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1ImageToVideoRequest {
Veo3p1ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine),
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ImageToVideoDuration>,
Option<Veo3p1ImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(true), 160),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(true), 480),
// Defaults: 8s, 720p, audio on
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
Veo3p1ImageToVideoAspectRatio::Auto,
Veo3p1ImageToVideoAspectRatio::SixteenByNine,
Veo3p1ImageToVideoAspectRatio::NineBySixteen,
];
let baseline = {
let mut r = make_request(
Some(Veo3p1ImageToVideoDuration::EightSeconds),
Some(Veo3p1ImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(aspect_ratios[0]);
r.calculate_cost_in_cents()
};
for ar in aspect_ratios {
let mut r = make_request(
Some(Veo3p1ImageToVideoDuration::EightSeconds),
Some(Veo3p1ImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ImageToVideoOutput {
pub video: Veo3p1ImageToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1_fast::raw_request::{
Veo3p1FastImageToVideoInput, Veo3p1FastImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastImageToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/image-to-video";
type RawRequest = Veo3p1FastImageToVideoInput;
type RawResponse = Veo3p1FastImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1FastImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
// Only the two required fields remain on the wire.
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1FastImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1FastImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1FastImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::image::veo_3p1_fast::api::{
Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest, Veo3p1FastImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 Fast per-second pricing (see the text
// module for the canonical rate table):
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FastImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastImageToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastImageToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1FastImageToVideoDuration>,
resolution: Option<Veo3p1FastImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastImageToVideoRequest {
Veo3p1FastImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine),
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastImageToVideoDuration>,
Option<Veo3p1FastImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(true), 60),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(true), 120),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(true), 280),
// Defaults: 8s, 720p, audio on
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
Veo3p1FastImageToVideoAspectRatio::Auto,
Veo3p1FastImageToVideoAspectRatio::SixteenByNine,
Veo3p1FastImageToVideoAspectRatio::NineBySixteen,
];
let baseline = {
let mut r = make_request(
Some(Veo3p1FastImageToVideoDuration::EightSeconds),
Some(Veo3p1FastImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(aspect_ratios[0]);
r.calculate_cost_in_cents()
};
for ar in aspect_ratios {
let mut r = make_request(
Some(Veo3p1FastImageToVideoDuration::EightSeconds),
Some(Veo3p1FastImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastImageToVideoOutput {
pub video: Veo3p1FastImageToVideoVideoFile,
}
@@ -0,0 +1,285 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1_lite::raw_request::{
Veo3p1LiteImageToVideoInput, Veo3p1LiteImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1LiteImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1LiteImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1LiteImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1LiteImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1LiteImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1LiteImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1LiteImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND
/// audio affect the per-second price.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1LiteImageToVideoResolution {
/// 1080p is billed at a higher per-second rate than 720p.
pub fn is_ten_eighty_p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1LiteImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1LiteImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/lite/image-to-video";
type RawRequest = Veo3p1LiteImageToVideoInput;
type RawResponse = Veo3p1LiteImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1LiteImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1LiteImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1LiteImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1LiteImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1LiteImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1LiteImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1LiteImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/lite/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,102 @@
use crate::requests::api::video::image::veo_3p1_lite::api::{
Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest, Veo3p1LiteImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_lite::cost::{
veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 Lite per-second pricing (see the text
// module for the canonical rate table). Both resolution and audio matter:
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
impl FalRequestCostCalculator for Veo3p1LiteImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1LiteImageToVideoDuration::EightSeconds)
.to_seconds();
let is_1080p = self.resolution
.unwrap_or(Veo3p1LiteImageToVideoResolution::SevenTwentyP)
.is_ten_eighty_p();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on);
veo_3p1_lite_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1LiteImageToVideoDuration>,
resolution: Option<Veo3p1LiteImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1LiteImageToVideoRequest {
Veo3p1LiteImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1LiteImageToVideoDuration>,
Option<Veo3p1LiteImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(false), 12),
(Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 20),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 40),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(false), 40),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true), 64),
// Defaults: 8s, 720p, audio on
(None, None, None, 40),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true));
r.aspect_ratio = Some(Veo3p1LiteImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3p1LiteImageToVideoAspectRatio::Auto,
Veo3p1LiteImageToVideoAspectRatio::SixteenByNine,
Veo3p1LiteImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/lite/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/lite/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1LiteImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteImageToVideoOutput {
pub video: Veo3p1LiteImageToVideoVideoFile,
}
@@ -0,0 +1,197 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::vidu_q3::raw_request::{
ViduQ3ImageToVideoInput, ViduQ3ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ImageToVideoRequest {
/// Text prompt (max 2000 characters). Optional server-side; pass an empty
/// string for a pure image-to-video generation.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Output resolution. fal's default is `720p` when `None`. Note fal rejects
/// `360p` when `end_image_url` is set.
pub resolution: Option<ViduQ3ImageToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ImageToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ImageToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video";
type RawRequest = ViduQ3ImageToVideoInput;
type RawResponse = ViduQ3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
seed: self.seed,
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
seed: None,
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ImageToVideoRequest {
prompt: "a smooth transition between the two frames".to_string(),
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
duration: Some(5),
seed: Some(1234),
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: Some("https://example.com/end.png".to_string()),
duration: Some(7),
seed: Some(42),
resolution: Some(ViduQ3ImageToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png"));
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: Some(777),
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ImageToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,78 @@
use crate::requests::api::video::image::vidu_q3::api::{
ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Vidu Q3 per-second pricing (see the text module for
// the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ImageToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ImageToVideoResolution>,
) -> ViduQ3ImageToVideoRequest {
ViduQ3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
end_image_url: None,
duration,
seed: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ImageToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ImageToVideoResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ImageToVideoResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 154),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// An end frame does not change the bill (only resolution + duration do).
#[test]
fn cost_is_independent_of_end_frame() {
let without = make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let with = ViduQ3ImageToVideoRequest {
end_image_url: Some("https://example.com/end.png".to_string()),
..make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(without, with);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/api>
///
/// NB: this endpoint has no `aspect_ratio` (it is derived from the input
/// image). A `prompt` is optional server-side but always sent here.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ImageToVideoInput {
/// Text prompt (max 2000 characters). Optional server-side.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
/// (360p is unavailable when `end_image_url` is set.)
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ImageToVideoOutput {
pub video: ViduQ3ImageToVideoVideoFile,
}
@@ -0,0 +1,197 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::vidu_q3_turbo::raw_request::{
ViduQ3TurboImageToVideoInput, ViduQ3TurboImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3TurboImageToVideoRequest {
/// Text prompt (max 2000 characters). Optional server-side; pass an empty
/// string for a pure image-to-video generation.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Output resolution. fal's default is `720p` when `None`. Note fal rejects
/// `360p` when `end_image_url` is set.
pub resolution: Option<ViduQ3TurboImageToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
/// Vidu Q3 Turbo resolutions. 720p/1080p bill at 2.2× the 360p/540p rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TurboImageToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3TurboImageToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3TurboImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video/turbo";
type RawRequest = ViduQ3TurboImageToVideoInput;
type RawResponse = ViduQ3TurboImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
seed: self.seed,
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
seed: None,
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboImageToVideoRequest {
prompt: "a smooth transition between the two frames".to_string(),
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
duration: Some(5),
seed: Some(1234),
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: Some("https://example.com/end.png".to_string()),
duration: Some(7),
seed: Some(42),
resolution: Some(ViduQ3TurboImageToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png"));
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: Some(777),
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3TurboImageToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/image-to-video/turbo",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,80 @@
use crate::requests::api::video::image::vidu_q3_turbo::api::{
ViduQ3TurboImageToVideoRequest, ViduQ3TurboImageToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3_turbo::cost::{
vidu_q3_turbo_cost_cents, vidu_q3_turbo_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Vidu Q3 Turbo per-second pricing (see the text
// module for the canonical rate table):
// 360p / 540p: $0.035/sec
// 720p / 1080p: $0.077/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3TurboImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3TurboImageToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_turbo_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3TurboImageToVideoResolution>,
) -> ViduQ3TurboImageToVideoRequest {
ViduQ3TurboImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
end_image_url: None,
duration,
seed: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3TurboImageToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), 18), // 17.5 → 18
(Some(5), Some(ViduQ3TurboImageToVideoResolution::FiveFortyP), 18),
(Some(5), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 39), // 38.5 → 39
(Some(5), Some(ViduQ3TurboImageToVideoResolution::TenEightyP), 39),
(Some(10), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 77),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 39),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// An end frame does not change the bill (only resolution + duration do).
#[test]
fn cost_is_independent_of_end_frame() {
let without = make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let with = ViduQ3TurboImageToVideoRequest {
end_image_url: Some("https://example.com/end.png".to_string()),
..make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(without, with);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video/turbo`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/turbo/api>
///
/// NB: this endpoint has no `aspect_ratio` (derived from the input image).
/// A `prompt` is optional server-side but always sent here.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3TurboImageToVideoInput {
/// Text prompt (max 2000 characters). Optional server-side.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
/// (360p is unavailable when `end_image_url` is set.)
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboImageToVideoOutput {
pub video: ViduQ3TurboImageToVideoVideoFile,
}
@@ -0,0 +1,3 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1::raw_request::{
Veo3p1FirstLastFrameToVideoInput, Veo3p1FirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FirstLastFrameToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/first-last-frame-to-video";
type RawRequest = Veo3p1FirstLastFrameToVideoInput;
type RawResponse = Veo3p1FirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1::api::{
Veo3p1FirstLastFrameToVideoDuration, Veo3p1FirstLastFrameToVideoRequest,
Veo3p1FirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 (non-fast) per-second pricing
// (see the text module for the canonical rate table):
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1FirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FirstLastFrameToVideoRequest {
Veo3p1FirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FirstLastFrameToVideoDuration>,
Option<Veo3p1FirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1FirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 240),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(true), 480),
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FirstLastFrameToVideoOutput {
pub video: Veo3p1FirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1_fast::raw_request::{
Veo3p1FastFirstLastFrameToVideoInput, Veo3p1FastFirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastFirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastFirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastFirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastFirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastFirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastFirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastFirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastFirstLastFrameToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastFirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/first-last-frame-to-video";
type RawRequest = Veo3p1FastFirstLastFrameToVideoInput;
type RawResponse = Veo3p1FastFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastFirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastFirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1_fast::api::{
Veo3p1FastFirstLastFrameToVideoDuration, Veo3p1FastFirstLastFrameToVideoRequest,
Veo3p1FastFirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 Fast per-second pricing (see the
// text module for the canonical rate table):
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FastFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FastFirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1FastFirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastFirstLastFrameToVideoRequest {
Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastFirstLastFrameToVideoDuration>,
Option<Veo3p1FastFirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 90),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(true), 120),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(true), 280),
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastFirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastFirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastFirstLastFrameToVideoOutput {
pub video: Veo3p1FastFirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1_lite::raw_request::{
Veo3p1LiteFirstLastFrameToVideoInput, Veo3p1LiteFirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1LiteFirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1LiteFirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1LiteFirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1LiteFirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1LiteFirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND
/// audio affect the per-second price.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1LiteFirstLastFrameToVideoResolution {
/// 1080p is billed at a higher per-second rate than 720p.
pub fn is_ten_eighty_p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1LiteFirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1LiteFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/lite/first-last-frame-to-video";
type RawRequest = Veo3p1LiteFirstLastFrameToVideoInput;
type RawResponse = Veo3p1LiteFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1LiteFirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1LiteFirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/lite/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1_lite::api::{
Veo3p1LiteFirstLastFrameToVideoDuration, Veo3p1LiteFirstLastFrameToVideoRequest,
Veo3p1LiteFirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_lite::cost::{
veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 Lite per-second pricing (see the
// text module for the canonical rate table). Both resolution and audio matter:
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
impl FalRequestCostCalculator for Veo3p1LiteFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let is_1080p = self.resolution
.unwrap_or(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP)
.is_ten_eighty_p();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on);
veo_3p1_lite_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1LiteFirstLastFrameToVideoRequest {
Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 12),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 20),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 48),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(false), 40),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 64),
// Defaults: 8s, 720p, audio on
(None, None, None, 40),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/lite/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/lite/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1LiteFirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteFirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteFirstLastFrameToVideoOutput {
pub video: Veo3p1LiteFirstLastFrameToVideoVideoFile,
}
@@ -1,3 +1,6 @@
pub mod elements;
pub mod extend;
pub mod image;
pub mod text;
pub mod images;
pub mod reference;
pub mod text;
@@ -0,0 +1,4 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod vidu_q3_reference_to_video;
pub mod vidu_q3_reference_to_video_mix;
@@ -0,0 +1,275 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::veo_3p1::raw_request::{
Veo3p1ReferenceToVideoInput, Veo3p1ReferenceToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ReferenceToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Reference image URLs used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo3p1ReferenceToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1ReferenceToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ReferenceToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ReferenceToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ReferenceToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1ReferenceToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1ReferenceToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ReferenceToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ReferenceToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/reference-to-video";
type RawRequest = Veo3p1ReferenceToVideoInput;
type RawResponse = Veo3p1ReferenceToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_urls: self.image_urls.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ReferenceToVideoRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ReferenceToVideoRequest {
prompt: "the dogs play together in a snowy field".to_string(),
image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
aspect_ratio: None,
duration: Some(Veo3p1ReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1ReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::NineBySixteen),
duration: Some(Veo3p1ReferenceToVideoDuration::SixSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::FourK),
generate_audio: Some(true),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1ReferenceToVideoSafetyTolerance::Level5),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.image_urls.len(), 2);
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("4k"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("5"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_urls": ["https://example.com/a.png"] }),
);
}
/// This endpoint's schema has no `negative_prompt`/`seed` fields, so they
/// must never appear on the wire regardless of other settings.
#[test]
fn raw_request_never_emits_negative_prompt_or_seed() {
let request = Veo3p1ReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ReferenceToVideoDuration::EightSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::TenEightyP),
generate_audio: Some(true),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1ReferenceToVideoSafetyTolerance::Level4),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert!(json.get("negative_prompt").is_none(), "unexpected negative_prompt: {json}");
assert!(json.get("seed").is_none(), "unexpected seed: {json}");
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ReferenceToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/reference-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::reference::veo_3p1::api::{
Veo3p1ReferenceToVideoDuration, Veo3p1ReferenceToVideoRequest, Veo3p1ReferenceToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Reference-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the
// text module for the canonical rate table). fal's reference-to-video docs
// explicitly document the 4k tier for this endpoint:
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1ReferenceToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1ReferenceToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1ReferenceToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1ReferenceToVideoDuration>,
resolution: Option<Veo3p1ReferenceToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1ReferenceToVideoRequest {
Veo3p1ReferenceToVideoRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration,
resolution,
generate_audio,
auto_fix: None,
safety_tolerance: None,
}
}
/// fal reference-to-video docs: "8 second video at 1080p with audio on will
/// cost $3.20" → 8s × $0.40 = 320¢.
#[test]
fn matches_documented_8s_1080p_audio_on_example() {
let cost = make_request(
Some(Veo3p1ReferenceToVideoDuration::EightSeconds),
Some(Veo3p1ReferenceToVideoResolution::TenEightyP),
Some(true),
).calculate_cost_in_cents();
assert_eq!(cost, 320);
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ReferenceToVideoDuration>,
Option<Veo3p1ReferenceToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ReferenceToVideoDuration::FourSeconds), Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1ReferenceToVideoDuration::FourSeconds), Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), Some(true), 160),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::FourK), Some(true), 480),
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = Veo3p1ReferenceToVideoRequest {
image_urls: vec!["https://example.com/a.png".to_string()],
..make_request(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
let many = Veo3p1ReferenceToVideoRequest {
image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
],
..make_request(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
assert_eq!(one, many);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/reference-to-video`.
/// Reference images drive subject/appearance consistency.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/reference-to-video/api>
///
/// NB: unlike the other Veo 3.1 modalities, this endpoint's schema does NOT
/// expose `negative_prompt` or `seed`.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ReferenceToVideoInput {
pub prompt: String,
/// URLs of the reference images used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ReferenceToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ReferenceToVideoOutput {
pub video: Veo3p1ReferenceToVideoVideoFile,
}
@@ -0,0 +1,275 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::veo_3p1_fast::raw_request::{
Veo3p1FastReferenceToVideoInput, Veo3p1FastReferenceToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastReferenceToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Reference image URLs used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo3p1FastReferenceToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastReferenceToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastReferenceToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastReferenceToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastReferenceToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastReferenceToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastReferenceToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastReferenceToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastReferenceToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/reference-to-video";
type RawRequest = Veo3p1FastReferenceToVideoInput;
type RawResponse = Veo3p1FastReferenceToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_urls: self.image_urls.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "the dogs play together in a snowy field".to_string(),
image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
aspect_ratio: None,
duration: Some(Veo3p1FastReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::NineBySixteen),
duration: Some(Veo3p1FastReferenceToVideoDuration::SixSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::FourK),
generate_audio: Some(true),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1FastReferenceToVideoSafetyTolerance::Level5),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.image_urls.len(), 2);
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("4k"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("5"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_urls": ["https://example.com/a.png"] }),
);
}
/// This endpoint's schema has no `negative_prompt`/`seed` fields, so they
/// must never appear on the wire regardless of other settings.
#[test]
fn raw_request_never_emits_negative_prompt_or_seed() {
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastReferenceToVideoDuration::EightSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::TenEightyP),
generate_audio: Some(true),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastReferenceToVideoSafetyTolerance::Level4),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert!(json.get("negative_prompt").is_none(), "unexpected negative_prompt: {json}");
assert!(json.get("seed").is_none(), "unexpected seed: {json}");
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastReferenceToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/reference-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,109 @@
use crate::requests::api::video::reference::veo_3p1_fast::api::{
Veo3p1FastReferenceToVideoDuration, Veo3p1FastReferenceToVideoRequest,
Veo3p1FastReferenceToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// PRICING NOTE — reference-to-video:
//
// fal's reference-to-video llms.txt documents a FLAT rate of $0.10/sec (audio
// off) / $0.15/sec (audio on) and does NOT mention a separate 4k tier — even
// though the endpoint exposes a "4k" resolution option. The sibling
// text/image/first-last endpoints DO document the 4k surcharge
// ($0.30/$0.35). Rather than risk undercharging on 4k reference generations,
// we mirror the rest of the Veo 3.1 Fast family and apply the 4k surcharge
// here too. For 720p/1080p this is identical to fal's documented flat rate;
// only 4k differs. If fal confirms reference-to-video is truly flat at 4k,
// drop the `four_k` branch and always bill the SD rate.
impl FalRequestCostCalculator for Veo3p1FastReferenceToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastReferenceToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastReferenceToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FastReferenceToVideoDuration>,
resolution: Option<Veo3p1FastReferenceToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastReferenceToVideoRequest {
Veo3p1FastReferenceToVideoRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration,
resolution,
generate_audio,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastReferenceToVideoDuration>,
Option<Veo3p1FastReferenceToVideoResolution>,
Option<bool>,
u64,
)] = &[
// 720p/1080p match fal's documented flat rate
(Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), Some(true), 60),
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true), 120),
// 4k applies the family surcharge (see PRICING NOTE)
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::FourK), Some(true), 280),
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = Veo3p1FastReferenceToVideoRequest {
image_urls: vec!["https://example.com/a.png".to_string()],
..make_request(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
let many = Veo3p1FastReferenceToVideoRequest {
image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
],
..make_request(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
assert_eq!(one, many);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/reference-to-video`.
/// Reference images drive subject/appearance consistency.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/reference-to-video/api>
///
/// NB: unlike the other Veo 3.1 Fast modalities, this endpoint's schema does
/// NOT expose `negative_prompt` or `seed`.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastReferenceToVideoInput {
pub prompt: String,
/// URLs of the reference images used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastReferenceToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastReferenceToVideoOutput {
pub video: Veo3p1FastReferenceToVideoVideoFile,
}
@@ -0,0 +1,232 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::vidu_q3_reference_to_video::raw_request::{
ViduQ3ReferenceToVideoInput, ViduQ3ReferenceToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ReferenceToVideoRequest {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Reference image URLs (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<ViduQ3ReferenceToVideoAspectRatio>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<ViduQ3ReferenceToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
impl ViduQ3ReferenceToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
Self::FourByThree => "4:3",
Self::ThreeByFour => "3:4",
Self::Square => "1:1",
}
}
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ReferenceToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ReferenceToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video";
type RawRequest = ViduQ3ReferenceToVideoInput;
type RawResponse = ViduQ3ReferenceToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
reference_image_urls: self.reference_image_urls.clone(),
duration: self.duration,
seed: self.seed,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
reference_image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoRequest {
prompt: "the dogs play together in a snowy field".to_string(),
reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
duration: Some(5),
seed: Some(1234),
aspect_ratio: None,
resolution: Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ReferenceToVideoRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
duration: Some(12),
seed: Some(42),
aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::FourByThree),
resolution: Some(ViduQ3ReferenceToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.reference_image_urls.len(), 2);
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ReferenceToVideoRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: None,
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ReferenceToVideoRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: Some(777),
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine, "16:9"),
(ViduQ3ReferenceToVideoAspectRatio::NineBySixteen, "9:16"),
(ViduQ3ReferenceToVideoAspectRatio::FourByThree, "4:3"),
(ViduQ3ReferenceToVideoAspectRatio::ThreeByFour, "3:4"),
(ViduQ3ReferenceToVideoAspectRatio::Square, "1:1"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ReferenceToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/reference-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,84 @@
use crate::requests::api::video::reference::vidu_q3_reference_to_video::api::{
ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Reference-to-video shares the Vidu Q3 per-second pricing (see the text module
// for the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ReferenceToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ReferenceToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ReferenceToVideoResolution>,
) -> ViduQ3ReferenceToVideoRequest {
ViduQ3ReferenceToVideoRequest {
prompt: "test".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration,
seed: None,
aspect_ratio: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ReferenceToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ReferenceToVideoResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 154),
(Some(3), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 47), // 46.2 → 47
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let four = ViduQ3ReferenceToVideoRequest {
reference_image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
"https://example.com/d.png".to_string(),
],
..make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(one, four);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/reference-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ReferenceToVideoInput {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// URLs of the reference images (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoOutput {
pub video: ViduQ3ReferenceToVideoVideoFile,
}
@@ -0,0 +1,219 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::raw_request::{
ViduQ3ReferenceToVideoMixInput, ViduQ3ReferenceToVideoMixOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ReferenceToVideoMixRequest {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Reference image URLs (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<ViduQ3ReferenceToVideoMixAspectRatio>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<ViduQ3ReferenceToVideoMixResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoMixAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
impl ViduQ3ReferenceToVideoMixAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
Self::FourByThree => "4:3",
Self::ThreeByFour => "3:4",
Self::Square => "1:1",
}
}
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoMixResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ReferenceToVideoMixResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ReferenceToVideoMixRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video/mix";
type RawRequest = ViduQ3ReferenceToVideoMixInput;
type RawResponse = ViduQ3ReferenceToVideoMixOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
reference_image_urls: self.reference_image_urls.clone(),
duration: self.duration,
seed: self.seed,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_mix_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
reference_image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine),
resolution: Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_mix_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "the dogs play together in a snowy field".to_string(),
reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
duration: Some(5),
seed: Some(1234),
aspect_ratio: None,
resolution: Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
duration: Some(12),
seed: Some(42),
aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::FourByThree),
resolution: Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.reference_image_urls.len(), 2);
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: None,
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: Some(777),
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ReferenceToVideoMixRequest::ENDPOINT,
"fal-ai/vidu/q3/reference-to-video/mix",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,84 @@
use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{
ViduQ3ReferenceToVideoMixRequest, ViduQ3ReferenceToVideoMixResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// The "mix" variant shares the Vidu Q3 per-second pricing (see the text module
// for the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ReferenceToVideoMixRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ReferenceToVideoMixResolution>,
) -> ViduQ3ReferenceToVideoMixRequest {
ViduQ3ReferenceToVideoMixRequest {
prompt: "test".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration,
seed: None,
aspect_ratio: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents) — identical to base Vidu Q3.
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ReferenceToVideoMixResolution>, u64)] = &[
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 154),
(Some(3), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 47), // 46.2 → 47
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)).calculate_cost_in_cents();
let four = ViduQ3ReferenceToVideoMixRequest {
reference_image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
"https://example.com/d.png".to_string(),
],
..make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(one, four);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video/mix`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/reference-to-video/mix/api>
///
/// The "mix" variant shares the base reference-to-video input schema; it
/// differs only in the server-side generation behavior.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ReferenceToVideoMixInput {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// URLs of the reference images (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoMixVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoMixOutput {
pub video: ViduQ3ReferenceToVideoMixVideoFile,
}
@@ -2,3 +2,11 @@ pub mod kling_1p6_pro_text_to_video;
pub mod kling_2p5_turbo_pro_text_to_video;
pub mod kling_2p6_pro_text_to_video;
pub mod kling_3p0_standard_text_to_video;
pub mod veo_2;
pub mod veo_3;
pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -0,0 +1,221 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::veo_2::raw_request::{
Veo2TextToVideoInput, Veo2TextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo2TextToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo2TextToVideoAspectRatio>,
/// Video duration. fal's default is `5s` when `None`.
pub duration: Option<Veo2TextToVideoDuration>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to enhance the prompt before generation. fal's default is `true`
/// when `None`.
pub enhance_prompt: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `true` when `None`.
pub auto_fix: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2TextToVideoAspectRatio {
NineBySixteen,
SixteenByNine,
Square,
}
impl Veo2TextToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::NineBySixteen => "9:16",
Self::SixteenByNine => "16:9",
Self::Square => "1:1",
}
}
}
/// Veo 2 supports 5–8 second durations (default 5s), unlike the Veo 3 family's
/// 4/6/8.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2TextToVideoDuration {
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
}
impl Veo2TextToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FiveSeconds => "5s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
impl FalEndpoint for Veo2TextToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo2";
type RawRequest = Veo2TextToVideoInput;
type RawResponse = Veo2TextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
enhance_prompt: self.enhance_prompt,
seed: self.seed,
auto_fix: self.auto_fix,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2TextToVideoRequest {
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo2TextToVideoDuration::FiveSeconds),
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2TextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo2TextToVideoDuration::FiveSeconds),
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo2TextToVideoRequest {
prompt: "p".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::Square),
duration: Some(Veo2TextToVideoDuration::EightSeconds),
negative_prompt: Some("blurry".to_string()),
enhance_prompt: Some(false),
seed: Some(42),
auto_fix: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1"));
assert_eq!(raw.duration.as_deref(), Some("8s"));
assert_eq!(raw.negative_prompt.as_deref(), Some("blurry"));
assert_eq!(raw.enhance_prompt, Some(false));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.auto_fix, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo2TextToVideoRequest {
prompt: "minimal".to_string(),
aspect_ratio: None,
duration: None,
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo2TextToVideoAspectRatio::NineBySixteen, "9:16"),
(Veo2TextToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo2TextToVideoAspectRatio::Square, "1:1"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo2TextToVideoDuration::FiveSeconds, "5s", 5),
(Veo2TextToVideoDuration::SixSeconds, "6s", 6),
(Veo2TextToVideoDuration::SevenSeconds, "7s", 7),
(Veo2TextToVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo2TextToVideoRequest::ENDPOINT, "fal-ai/veo2");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,110 @@
use crate::requests::api::video::text::veo_2::api::{Veo2TextToVideoDuration, Veo2TextToVideoRequest};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Veo 2 pricing (see https://fal.ai/models/fal-ai/veo2):
// "For 5s video your request will cost $2.50. For every additional second
// you will be charged $0.50."
//
// This is a flat base + per-extra-second model (NOT the per-second-rate model
// the Veo 3 family uses), and it does not vary by audio, resolution, or aspect
// ratio. All amounts are exact whole cents. Shared by text + image modalities.
const BASE_COST_CENTS: UsdCents = 250; // 5s
const ADDITIONAL_SECOND_COST_CENTS: UsdCents = 50;
const BASE_DURATION_SECS: u64 = 5;
/// Veo 2 cost in whole cents for a given duration in seconds.
/// `250 + (secs - 5) × 50`; durations below 5s (not reachable via the typed
/// enum) clamp to the base.
pub(crate) fn veo_2_cost_cents(duration_secs: u64) -> UsdCents {
let extra_secs = duration_secs.saturating_sub(BASE_DURATION_SECS);
BASE_COST_CENTS + extra_secs * ADDITIONAL_SECOND_COST_CENTS
}
impl FalRequestCostCalculator for Veo2TextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = self.duration
.unwrap_or(Veo2TextToVideoDuration::FiveSeconds)
.to_seconds();
veo_2_cost_cents(duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::veo_2::api::Veo2TextToVideoAspectRatio;
fn make_request(duration: Option<Veo2TextToVideoDuration>) -> Veo2TextToVideoRequest {
Veo2TextToVideoRequest {
prompt: "test".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration,
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
}
}
/// fal: "For 5s video your request will cost $2.50" → 250¢.
#[test]
fn matches_documented_5s_example() {
assert_eq!(make_request(Some(Veo2TextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 250);
}
/// fal i2v example: a 6-second video costs $3.00 ($2.50 + $0.50) → 300¢.
#[test]
fn matches_documented_6s_example() {
assert_eq!(make_request(Some(Veo2TextToVideoDuration::SixSeconds)).calculate_cost_in_cents(), 300);
}
mod cost_table {
use super::*;
// (duration, expected_cents): 250 base + 50 per second over 5s.
const COST_TABLE: &[(Option<Veo2TextToVideoDuration>, u64)] = &[
(Some(Veo2TextToVideoDuration::FiveSeconds), 250),
(Some(Veo2TextToVideoDuration::SixSeconds), 300),
(Some(Veo2TextToVideoDuration::SevenSeconds), 350),
(Some(Veo2TextToVideoDuration::EightSeconds), 400),
// Default: duration=None → 5s
(None, 250),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
assert_eq!(
make_request(duration).calculate_cost_in_cents(),
expected,
"duration={duration:?}",
);
}
}
#[test]
fn each_extra_second_adds_fifty_cents() {
let five = make_request(Some(Veo2TextToVideoDuration::FiveSeconds)).calculate_cost_in_cents();
let six = make_request(Some(Veo2TextToVideoDuration::SixSeconds)).calculate_cost_in_cents();
assert_eq!(six - five, 50);
}
/// Aspect ratio, negative prompt, enhance_prompt, seed, and auto_fix must
/// not affect the bill.
#[test]
fn cost_ignores_non_billing_fields() {
let baseline = make_request(Some(Veo2TextToVideoDuration::EightSeconds)).calculate_cost_in_cents();
let embellished = Veo2TextToVideoRequest {
prompt: "different".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::Square),
duration: Some(Veo2TextToVideoDuration::EightSeconds),
negative_prompt: Some("noise".to_string()),
enhance_prompt: Some(false),
seed: Some(99),
auto_fix: Some(false),
}.calculate_cost_in_cents();
assert_eq!(baseline, embellished);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;

Some files were not shown because too many files have changed in this diff Show More