Compare commits

...
Author SHA1 Message Date
Tanish Manku bb7e631834 Audio: consolidate sound system into SoundManager, fix bootstrap + master toggle
- Move sound catalog registration off PageScene mount and into a single
  SoundManager.install() call in GlobalSettingsManager so sounds exist at
  app boot (previously silent until PageScene mounted).
- Honor the play_sounds master toggle in all five event playback sites
  via SoundManager.playEvent helpers. Settings preview keeps firing
  regardless (handled by SoundManager.playPreview).
- Replace per-callsite GetAppPreferences + SoundRegistry boilerplate in
  the four generation event hooks and the file-deleted handler with
  one-line SoundManager.* calls.
- Dedupe SOUND_OPTIONS: AudioSettingsPane now reads SoundManager.OPTIONS,
  derived from a single declarative SOUNDS array (label-bearing entries
  are user-selectable; others are still registered for code-driven use).
- Narrow the soundboard public barrel to SoundManager + SoundOption only;
  SoundRegistry and SoundEffect are now lib-internal.
2026-04-30 09:02:26 -07:00
Tanish Manku 2b5e509a29 PageScene: move engine lifecycle flags + canvas refs into PageSceneStore
The last batch of PageScene-owned signals — `is3DPageMounted`,
`is3DEditorInitialized`, `is3DSceneLoaded`, and the three canvas-ref
signals (sceneContainer, editorCanvas, camViewCanvas) — now live on
the Zustand store. EngineProvider, the canvas components, the
useEditorCanvas hook, and the cross-app TopBar consumer all read from
the store instead. The helper signals in editor.ts are gone; the
`set3DPageMounted` / `setIs3DEditorInitialized` / `setIs3DSceneLoaded`
exports stay as thin wrappers around the store actions for existing
callers.
2026-04-29 15:16:29 -07:00
Tanish Manku 28db87c1b2 PageScene + libs: drop camera/promptBox signals, switch shared libs to plain props
PromptBox3D and CameraSettingsModal now accept plain values + setters
instead of `Signal<T>` props. The camera/focalLengthDragging/
isPromptBoxFocused signals are gone — store is the single source of
truth. Engine code (editor/save_manager/scene) reads/writes via
`usePageSceneStore.getState()` and store actions. Demo asset signals
are now plain constants (they were never reactive).
2026-04-29 15:12:13 -07:00
Tanish Manku 292cf33f57 PageScene: route setCameraAspect + save_manager directly through PageSceneStore 2026-04-29 15:00:48 -07:00
Tanish Manku 58b7cd0cd4 PageScene: route useFreeCam through PageSceneStore for prompt-box focus check 2026-04-29 14:59:54 -07:00
Tanish Manku 101b735c35 PageScene: migrate outliner signals to PageSceneStore 2026-04-29 14:59:06 -07:00
Tanish Manku 32291ce080 PageScene: migrate objectPanel signals to PageSceneStore 2026-04-29 14:56:05 -07:00
Tanish Manku cd9c06d2ce PageScene: migrate precisionSelector signals to PageSceneStore 2026-04-29 14:54:24 -07:00
Tanish Manku a780779f7a PageScene: migrate dragAndDrop signals to PageSceneStore 2026-04-29 14:53:10 -07:00
Tanish Manku 076b7fe4a4 PageScene: extend PageSceneStore with drag/objectPanel/outliner/precisionSelector fields 2026-04-29 14:52:05 -07:00
Tanish Manku 7eef728d22 PageScene: trim api_manager + drop dead model/error files
api_manager.ts goes from 460 lines to ~135. The video-render
pipeline (uploadMediaFrameGeneration, stylizeVideo,
StylizeVideoParams, getMediaBatch, getMediaFile, uploadMedia,
gltfExport) was unreachable after videoGeneration was deleted.
Surviving public methods:

- saveSceneState (called by save_manager)
- loadSceneState (called by save_manager)
- uploadMediaSceneThumbnail (used internally by saveSceneState)

Also drops the local ArtStyle / Visibility / MediaFile types —
they only fed the deleted methods. The ArtStyle enum has a
separate copy in ~/enums/ArtStyle.ts that other code uses.

Delete two files that became unreferenced:
- engine/APIManagerResponseError.ts (duplicate of the local class
  in api_manager.ts; nothing imported it)
- models/generationOptions.ts (IGenerationOptions/GenerationOptions
  fed editor.generation_options, deleted last commit)

Net: -1 typecheck error, -326 lines.
2026-04-29 14:12:03 -07:00
Tanish Manku d09c7320fd PageScene: delete misc dead files surfaced by stylize cleanup
Files with zero importers across the artcraft app:

- constants/RandomTexts.ts — RandomTextsPositive/Negative seeded
  prompt suggestions in the deleted StylizeTab.
- composable_engines/scene_format.tsx — empty (1 line).
- constants/page.ts and the matching currentPage signal in
  signals/global.ts — Pages enum + signal, neither read.
- enums/StylizeTabPages.ts and enums/AudioTabPages.ts — re-exported
  via enums/index.ts but no consumer.
- models/track.ts, models/message.ts, models/voice.ts, models/tts.ts
  — timeline / TTS / audio-panel types from deleted features.
- styleList.ts — used to seed selectedArtStyle's default; that
  store field is gone.
- models/types.tsx had a duplicate AudioTabPages enum (separate
  from the deleted one in enums/) — drop the duplicate too.

models/index.ts and enums/index.ts trimmed to match. Error count
unchanged (151) — none of these had broken type errors anyway.
2026-04-29 14:08:08 -07:00
Tanish Manku 0ca91e29b7 PageScene: drop orphaned stylize state
Strip the stylize fields that nothing reads anymore now that the
StyleMoreOptions / StyleSelection UI is gone. From PageSceneStore:
selectedArtStyle, upscale, faceDetail, styleStrength, lipSync,
cinematic, enginePreProcessing, globalIPAMediaToken, adapterImage,
positivePrompt, negativePrompt, isUserInputPositive,
isUserInputNegative, showNegativePrompt — and the matching setters
+ resetSceneGenerationMetadata. Drop the styleList / ArtStyle
imports; both go unused here.

From editor.ts: drop negative_prompt, art_style,
engine_preprocessing, generation_options, globalIpAdapterImage.
Keep positive_prompt — PromptBox3D writes it via setEnginePrompt
(PageEditor.tsx) and save_manager round-trips it.

Trim save_manager.loadFromJson to the prompt-only path (legacy
saves with stylize keys load fine; the now-removed if-guards just
silently ignored them anyway). Trim SceneMetadata to
{ positivePrompt, cameraAspectRatio } and the matching
SceneGenereationMetaData interface.

ControlsTopButtons.handleResetScene drops its now-removed store
call.
2026-04-29 14:07:03 -07:00
Tanish Manku 8af4657393 PageScene: delete orphan StyleMoreOptions and StyleSelection UI
Both component trees survived earlier cleanup but never render
from PageEditor or PageScene. They were the last UI consumers
of the stylize store fields; deleting them sets up a follow-up
that drops those store fields entirely.

Net: -17 typecheck errors (the orphan files were the source of
many missing-import errors).
2026-04-29 14:04:02 -07:00
Tanish Manku f8ee94e538 Revert moodboard-html files committed by mistake on this branch
cardLayout.ts, MoodboardHtmlColorPicker.tsx, findDropTarget.ts, and
CardNodeHtml.tsx were swept into the PageEnigma -> PageScene rename
commit (76add858c9) on this 3D-refactor branch. They belong on
moodboard-page-html.

The file content is preserved on the local-only moodboard-rescue
branch (pointing at 01e0e27037). To carry them back over:

  git checkout moodboard-page-html
  git checkout moodboard-rescue --     frontend/apps/artcraft/app/src/pages/PageMoodboard/cardLayout.ts     frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlColorPicker.tsx     frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts     frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx
  git commit
2026-04-29 12:29:58 -07:00
Tanish Manku 01e0e27037 PageScene: route PoseModeSelector through getActiveEditor()
The Enter Pose Mode button reached through a window.__mouseControls
global handle that MouseControls' constructor set up. With
getActiveEditor() already exposing the live editor instance for
non-React callers (used in the camera-view toggle, the keymap),
the global is unnecessary.

Drop the (window as any).__mouseControls = this assignment in
MouseControls and the matching Window augmentation in
PoseModeSelector. Replace the click handler's reach with
getActiveEditor()?.mouse_controls?.toggleFKMode() — same shape
the keymap entry already uses.
2026-04-29 11:33:30 -07:00
Tanish Manku 794f65b517 PageScene: delete unused models/sidePanel.ts
The Tab interface in this file was the type for the deleted
SidePanel/tabList.tsx tab list. Grep across app/src/ confirms no
consumer remains; AssetModal uses its own local AssetTab type.
Drop the file and the matching re-export in models/index.ts.
2026-04-29 11:32:48 -07:00
Tanish Manku c53b407952 PageScene: drop unused imports surfaced by deletions
EditorStates is no longer referenced from editor.ts now that
PREVIEW is gone from the enum and switchPreview/switchEdit are
deleted. EngineCanvases.tsx never used useContext/EngineContext;
the imports were leftover from earlier work.

Net: -3 typecheck errors.
2026-04-29 10:03:23 -07:00
Tanish Manku e0611e0147 PageScene: drop document.mouseover gate + dead selectedCanvas
The mouseOverEventHandler / enableFreeCamControls /
disableFreeCamControls trio existed to track when the cursor was on
the viewport canvas and toggle FreeCam.enabled. With useFreeCam
gating itself on editorState === CAMERA_VIEW, that gate is
redundant; the only remaining consumer of selectedCanvas was
MouseControls.onkeydown's space-bar branch, which is unreachable
after the previous keyboard rewrite.

Removes the last document.addEventListener call from PageScene.
2026-04-29 10:02:09 -07:00
Tanish Manku 8a03449128 PageScene: keymap + useViewportKeyboard hook
Replace MouseControls.onkeydown's giant if-else chain with a
declarative keymap:

- engine/keymap.ts: KeyBinding type + buildKeymap() returning every
  one-shot viewport shortcut as one entry. Each carries label /
  group metadata so a future Ctrl-hold cheatsheet overlay can read
  the same array. dispatchBinding() does the modifier match
  (treating Ctrl and Meta as equivalent for macOS).
- hooks/useViewportKeyboard.ts: attaches a single keydown listener
  that gates on hotkeyStatus + editable-element checks, then walks
  the keymap. Held movement keys (W/A/S/D etc.) live in useFreeCam
  since they're continuous; this hook is for one-shots only.

The listener is on document, not the canvas, deliberately: canvas
focus is finicky and would require the user to click the viewport
before T/R/G work. The editable-element guard prevents the keymap
from firing while inputs are focused.

MouseControls.onkeydown is now unreachable (SceneManager's window
listeners are no-op since the previous commit, and useViewportKeyboard
goes straight to keymap dispatch). The class itself stays for now —
it still implements selection / FK / pointer-lock state.
2026-04-29 10:00:56 -07:00
Tanish Manku e4af73c9ac PageScene: useViewportPointer hook (canvas-scoped)
The old SceneManager.attachEventListeners() attached window-level
mousemove / click / mousedown / mouseup / keydown handlers in dev
mode and dispatched them into MouseControls. Replace with two pieces:

- hooks/useViewportPointer.ts: canvas-scoped pointer listeners
  that call into editor.mouse_controls. NDC pointer coords are
  computed via the new engine/pointer.ts helpers (ndcFromClient,
  applyNdcToVector2). Selection clicks suppress while the
  free-cam owns input (CAMERA_VIEW state).
- engine/pointer.ts: pure NDC coordinate translation, no DOM
  side-effects.

SceneManager.attachEventListeners / detachEventListeners are now
no-ops; kept for back-compat with the editor lifecycle so future
follow-ups can carve them out cleanly.

MouseControls itself still owns selection / FK / drag state — its
dispatch methods (onMouseMove, onMouseClick, etc.) are now invoked
by the React hook instead of window listeners. A future PR can
dissolve MouseControls further; the goal here is "no window
listeners" + "React owns lifecycle".
2026-04-29 09:58:24 -07:00
Tanish Manku efa9a54bee PageScene: useFreeCam hook + delete free_cam.ts
The 446-line FreeCam class is gone. Its responsibilities split:

- Math (move/rotate vectors, pan/zoom from inputs, lookAt) lives in
  cameraMath.ts as pure functions, plus a freeCamFrameTick() that
  integrates the per-frame state.
- Listener attachment + drag/key state ownership moves into
  hooks/useFreeCam.ts. Listeners are scoped to the canvas (no
  window/document handlers); held-key state is reset on
  CAMERA_VIEW exit so a stale W keypress can't drift the camera.
- The shared FreeCamControlState struct flows through Editor via
  editor.setFreeCamState(state); the render loop reads it and
  calls freeCamFrameTick() each frame.

Side effects: keybinds_controls.ts now imports
isPointerLockSupported from browserChecks; helper.ts and
PageEditor's camera-select handler stop poking
.cameraViewControls and reset velocity via freeCamState instead.
PreviewEngineCamera's camera-view toggle now just flips the store
state — useFreeCam picks it up on its own (no more
enableFreeCamControls/disableFreeCamControls call site).
2026-04-29 09:55:40 -07:00
Tanish Manku 718b8261fd PageScene: extract cameraMath + browserChecks modules
Pure-function helpers carved out of FreeCam in preparation for the
hook rewrite:

- engine/cameraMath.ts: HeldMoveKeys / HeldRotateKeys types,
  moveVectorFromKeys, rotationVectorFromKeys, panFromDrag,
  zoomFromWheel, lookAtFromCamera, lerpVelocity. No DOM access, no
  signals.
- engine/browserChecks.ts: isSafariOrWebKit, hasApplePay, isSafari,
  isPointerLockSupported. Unrelated to camera math; were just
  hitchhiking in free_cam.ts.

free_cam.ts re-exports the browser checks for back-compat with
keybinds_controls.ts; both consumers will switch to the direct
imports when free_cam.ts is deleted in the next commit.
2026-04-29 09:50:16 -07:00
Tanish Manku 61c4936c29 PageScene: useEditorCanvas hook + tabIndex on viewport canvas
The Phase 6 hooks (useFreeCam, useViewportPointer, useViewportKeyboard)
need a React-shaped accessor for the canvas DOM node so their
useEffects re-run when the canvas mounts/unmounts. Promote the
existing editorCanvasSignal / camViewCanvasSignal into useEditorCanvas
/ useCamViewCanvas hooks that bridge signal -> React state via a
subscribe.

Add tabIndex={0} and outline:none to the editor canvas so it can
receive keyboard focus without showing a focus ring.
2026-04-29 09:48:59 -07:00
Tanish Manku c23e652499 PageScene: wire camera-view toggle button
handleButtonCameraView in PreviewEngineCamera was a TODO no-op.
Flip editorState (EDIT <-> CAMERA_VIEW) via the store, then call
editor.enableFreeCamControls() / disableFreeCamControls() on the
active editor. Drop the stale console.log useEffect that fired on
every state change.
2026-04-29 09:48:02 -07:00
Tanish Manku a26ebce483 PageScene: delete video generation entirely
The 3D editor no longer generates videos. Remove:

- engine/video_generation.ts (the VideoGeneration class)
- comps/GenerateModals/ (modal + MyMovies/Sharing/Activity/cards)
- comps/PreviewFrameImage.tsx (PREVIEW-state-only preview overlay)
- comps/ControlsTopButtons/TestFeaturesButtons.tsx (dev-only,
  rendered with debug={false})
- ~/components/signaled/TopBar/MyMoviesButton.tsx (was orphaned;
  no consumer)

editor.ts: drop the VideoGeneration import, field, instantiation,
and the dead helpers it powered (useCachedMediaTokens,
stopPlaybackAndUploadVideo, switchPreview/switchEdit, generateFrame,
generateVideo, checkAndUseCache).

PageSceneStore: drop previewSrc/viewMyMovies/generateMovieId/
generateProgress fields and setters.

EditorStates: drop the PREVIEW variant; PageEditor's state machine is
now EDIT <-> CAMERA_VIEW only. PREVIEW was set only by the deleted
video_generation.ts and read by PreviewFrameImage (deleted) and a few
gates in PreviewEngineCamera + ControlPanelSceneObject (now removed).

Net: -8 typecheck errors.
2026-04-29 09:47:14 -07:00
Tanish Manku 1d3da065a2 PageScene: drop dead side-panel store fields
sidePanelWidth, sidePanelVisible, dndSidePanelWidth and their setters
are no longer read by any rendered component. Drop them from
PageSceneStore and the lone setSidePanelWidth(340) call in
PageEditor's mount effect. PreviewFrameImage's width calc no longer
subtracts the (always-zero) panel width.
2026-04-29 09:42:38 -07:00
Tanish Manku b4a3eb88bf PageScene: delete dead side-panel tree
The <SidePanel /> component was never rendered from PageEditor.tsx; the
entire comps/SidePanel/, comps/SidePanelMenu/, and most of comps/SidePanelTabs/
tree was unreachable. Relocate the few files still consumed by
AssetModal (sharedComps/ItemElement{,s}.tsx, hooks/useUserObjects,
hooks/useFeaturedObjects, utilities/fetchMediaItems + misc) to
comps/AssetMenu/{shared,hooks,utilities}/, and the StyleOptions panel
used by StyleMoreOptions to its parent folder. Then delete the rest.

Net: -33 typecheck errors (the dead tree had its own type problems).
2026-04-29 09:41:21 -07:00
Tanish Manku 48ab5f73ee Phase 7: rename Editor/ -> engine/ and drop dead useSignals() calls
Editor/ -> engine/:
- Rename pages/PageScene/Editor/ -> pages/PageScene/engine/
- Update every "./Editor/...", "../Editor/...", "../../Editor/...",
  "~/pages/PageScene/Editor/..." import path. The class name `Editor`,
  `EditorState` types, and `editorState` field are unchanged -- only
  the directory is renamed
- ImageTo3DExperience also gets the new path

useSignals() drops where the component reads zero .value signals:
- comps/GenerateModals/Activity.tsx
- comps/SidePanelTabs/hooks/useFeatureFlags.tsx
- comps/SidePanelTabs/sharedComps/ItemElement.tsx
- comps/SidePanelTabs/tabComps/StylizeTab/PageStyleSelection.tsx
- comps/StyleMoreOptions/StyleMoreOptions.tsx

The remaining useSignals() callers still subscribe to genuine signals
(pageHeight/pageWidth from ~/signals, outlinerIsShowing,
sceneContainerSignal, etc.) and need to stay until those signals migrate.

Final grep audit confirms no residue:
- "PageEnigma" -- 0 hits
- "from \"...Editor/...\"" import paths -- 0 hits
- Queue.publish / Queue.subscribe / fromEngineActions /
  toEngineActions / toTimelineActions -- 0 hits
- TimeLine, AudioEngine, TransformEngine, EmotionEngine, LipSyncEngine,
  CharacterAnimationEngine, ClipUI, TimelineState -- 0 hits

Typecheck unchanged at 144 errors in PageScene; zero new errors.
2026-04-28 23:47:34 -07:00
Tanish Manku 76add858c9 PageEnigma -> PageScene: rename to match the route param and the page title
The directory was named after a stale codename. The route is
/$sceneToken, the top bar reads "Edit Scene", and the page is the main
scene-editing workspace. Renaming the directory, top-level component,
and Zustand store accordingly:

- pages/PageEnigma/ -> pages/PageScene/
- pages/PageScene/PageEnigma.tsx -> PageScene.tsx
- pages/PageScene/PageEnigmaStore.ts -> PageSceneStore.ts
- usePageEnigmaStore -> usePageSceneStore (function rename across the
  whole codebase via sed)
- All ~/pages/PageEnigma/* import paths -> ~/pages/PageScene/*
- Component name PageEnigma -> PageScene in routes/_index.tsx and
  routes/$sceneToken.tsx

234 files touched, all mechanical. Typecheck error count holds at 144;
zero new errors introduced by the rename.
2026-04-28 23:44:26 -07:00
Tanish Manku e39e67195e PageEnigma refactor Phase 5g: migrate stylizeTab signal slice to the store
stylizeTab.ts (deleted). 11 consumers migrated:

UI components:
- StyleOptions: cinematic / upscale / faceDetail / lipSync /
  enginePreProcessing toggles drive the matching store setters; the
  upscale<->cinematic mutual-exclusion logic preserved
- StyleStrength: slider + number input read styleStrength from store,
  call setStyleStrength
- Prompts: textBufferPositive / textBufferNegative now positivePrompt /
  negativePrompt on the store; isUserInputPositive /
  isUserInputNegative / showNegativePrompt as fields with setters
- IPAdapter: adapterImage + globalIPAMediaToken from store; useShallow
  subscribes to all four needed fields/setters in one selector
- StylizeTab: selectedArtStyle from store, setSelectedArtStyle replaces
  the old setArtStyleSelection helper
- StyleSelection: same pattern -- selectedArtStyle and
  setSelectedArtStyle
- GenerateMovieButton: snapshots the entire stylize bag from the store
  with usePageEnigmaStore.getState() inside the click handler

Engine + persistence:
- SceneMetadata.getSceneGenerationMetaData reads cameraAspectRatio,
  globalIPAMediaToken, upscale, faceDetail, styleStrength, lipSync,
  cinematic, enginePreProcessing all from the store in one getState()
- save_manager.loadFromJson restores all stylize state through the
  store's setters instead of the old restoreSceneGenerationMetadata
  signal helper. globalIpAdapterImageMediaToken restoration also goes
  through setGlobalIPAMediaToken
- video_generation: setGlobalIPAMediaToken on upload + reads
  globalIPAMediaToken from store for the gen request payload
- ControlsTopButtons: new-scene flow calls
  usePageEnigmaStore.getState().resetSceneGenerationMetadata()

Bonus cleanup:
- SidePanelTabs reverted to inlining pageHeight - 64 (the previous
  edit got lost in a stash round-trip)
- IPAdapter dropped useSignals and uses useShallow for its 4-field
  selector

Net effect on typecheck: 145 -> 144 errors in PageEnigma.
2026-04-28 23:28:55 -07:00
Tanish Manku ea49ddf60a PageEnigma refactor Phase 5f: migrate sizing slice to the store
sizing.ts (deleted). Was already minimal -- just sidePanelWidth, the
last survivor after the timeline-related sizing exports were stripped
in Phase 2. Three consumers migrated:

- PageEditor's mount-time setup: store.setSidePanelWidth(340)
- PreviewFrameImage: pulls sidePanelWidth off the store alongside
  sidePanelVisible
- SidePanel: same pattern (already reading sidePanelVisible /
  dndSidePanelWidth from the store; sidePanelWidth joins them)
- StyleSelection: subscribes to sidePanelWidth via the store

signals/ down to 10 files: camera, demoAssets, dragAndDrop, engine,
objectPanel, outliner, precisionSelectorMenu, promptBox, stylizeTab.
2026-04-28 23:22:57 -07:00
Tanish Manku e0c04efa8f PageEnigma refactor Phase 5e: migrate selectedMode + sidePanel slices
selectedMode.ts (deleted):
- PoseModeSelector reads poseMode + showPoseControls from store, calls
  setPoseMode to toggle
- keybinds_controls migrates the t/r/g hotkeys (mode change),
  x (transform space toggle), 'Escape' (exits pose mode), object
  selection (sets showPoseControls), and FK toggle to the store
- editor.change_mode + toggleTransformSpace read/write transformSpace
  through usePageEnigmaStore.getState()
- Controls3D's mode dropdown + world/local pill read selectedMode and
  transformSpace from the store
- actions/setTransformMode no longer dual-writes the legacy signal
- store's PoseMode union changed from "select" | "edit" to
  "select" | "pose" to match what the engine actually checks

sidePanel.ts (deleted):
- SidePanel reads sidePanelVisible + dndSidePanelWidth from store
- TabTitle's close button calls setSidePanelVisible
- SidePanelMenu pulls sidePanelVisible + setSidePanelVisible
- PreviewFrameImage reads sidePanelVisible (was a signal, now store)
- sidePanelHeight (was a computed signal of pageHeight - 64) inlined
  at its three call sites: PreviewEngineCamera, SidePanelTabs,
  StyleSelection. cameraFilter has no consumers; it lives on the store
  for the next round.

signals/ down to 11 leaves: camera, demoAssets, dragAndDrop, engine,
objectPanel, outliner, precisionSelectorMenu, promptBox, sizing,
stylizeTab.
2026-04-28 23:21:13 -07:00
Tanish Manku dc140c0bc1 PageEnigma refactor Phase 5d: migrate hotkeys + selectedItem signal slices
hotkeys.ts (deleted):
- keybinds_controls reads usePageEnigmaStore.getState().hotkeyStatus.disabled
- ControlPanelSceneObject pulls disableHotkeyInput / enableHotkeyInput
  off the store and passes them to InputVector
- PageEditor pulls the same and passes them to LoginModal +
  PromptBox3D. DomLevels imported from PageEnigmaStore (replacing the
  signals/hotkeys re-export)

selectedItem.ts (deleted):
- No external consumers (dead since the timeline removal).
  selectedObject lives on the store; the legacy signal had no readers
  outside its own file.

signals/index.ts no longer re-exports either slice. Down to 13 files
in signals/.
2026-04-28 23:15:45 -07:00
Tanish Manku 21d3617970 PageEnigma refactor Phase 5c: migrate assetModal signal slice to the store
assetModal.ts (deleted):
- AssetMenu: setAssetModalVisibleDuringDrag(true) on open
- AssetModal: reads assetModalVisible / assetModalVisibleDuringDrag /
  reopenAfterDrag from the store, writes via the matching setters.
  Modal close drives setAssetModalVisible(false); the reopen-after-add
  checkbox writes through setReopenAfterDrag (note: store field is
  reopenAfterDrag, not the old reopenAfterDragSignal name)
- Controls3D: handleOpenModal flips both setters from the store; the
  empty-scene tooltip useEffect reads assetModalVisible from the store
- keybinds_controls: 'b' shortcut opens the asset modal via the store
- DragAndDrop/DndAsset: pointerdown clears
  assetModalVisibleDuringDrag, pointerup restores it from
  reopenAfterDrag, click (no drag) restores it to true. All through
  the store now.

signals/index.ts no longer re-exports assetModal. Four signal files
gone so far in Phase 5; signals/ now down to 13 leaves (camera,
demoAssets, dragAndDrop, engine, hotkeys, objectPanel, outliner,
precisionSelectorMenu, promptBox, selectedItem, selectedMode,
sidePanel, sizing, stylizeTab).
2026-04-28 23:13:57 -07:00
Tanish Manku 11a0ac82b6 PageEnigma refactor Phase 5b: migrate error, myMovies, editorOverlays signal slices
Three more signal files retired in favour of the Zustand store.

error.ts (deleted):
- ErrorDialog (in ~/components/signaled): reads showErrorDialog /
  errorDialogTitle / errorDialogMessage from the store, calls
  setShowErrorDialog
- SceneTitleInput (in TopBar): handleShowErrorDialog uses the store's
  setErrorDialog action

myMovies.ts (deleted):
- MyMoviesButton (in TopBar): writes via setGenerateMovieId /
  setViewMyMovies
- GenerateModals: useEffect-on-store replaces useSignalEffect; reads
  generateMovieId from store
- MyMovies and Sharing: read viewMyMovies from store, close via
  setViewMyMovies
- Sharing also reads generateMovieId from store

editorOverlays.ts (deleted):
- PageEditor's LoadingDots: reads editorLoader from store
- SceneContainer: reads editorLetterBox from store and passes through
  to Letterbox
- PreviewEngineCamera: reads editorLetterBox + toggleEditorLetterBox
  from store
- save_manager: showEditorLoader/hideEditorLoader become thin wrappers
  over the store actions, keeping the same call sites

signals/index.ts no longer re-exports any of the three deleted files.
2026-04-28 23:11:52 -07:00
Tanish Manku 4b0618e50b PageEnigma refactor Phase 5a: migrate engine.ts signal slice to the store
Migrates components and engine code from reading signals/engine.ts
(cameraAspectRatio, editorState, gridVisibility, previewSrc) to reading
the Zustand store.

Components migrated to usePageEnigmaStore reads:
- AspectRatioMenu (also dropped useSignals + collapsed two
  duplicated lookup ladders into ICON_BY_RATIO / LABEL_BY_RATIO)
- PreviewFrameImage
- Letterbox (no longer reactive via signals; reads camAspect from store)
- StyleButtons (editorState, previewSrc, setEditorState)
- Outliner (cameraAspectRatio)
- SidePanelMenu (editorState + setEditorState)
- ControlPanelSceneObject (editorState)
- PreviewEngineCamera (cameraAspectRatio + editorState)
- PageEditor (cameraAspectRatio for getScale; the signal version still
  flows into PromptBox3D as a prop)

Engine code migrated to usePageEnigmaStore.getState():
- editor.ts: snapShotOfCurrentFrame reads cameraAspectRatio + grid;
  switchPreview / switchEdit set editorState
- helper.ts: switchCameraView writes editorState
- video_generation.ts: generateFrame writes previewSrc + editorState
- SceneMetadata.ts: cameraAspectRatio for snapshot metadata
- scene.ts: subscribes to store gridVisible (not the signal)

signals/engine.ts becomes a thin compatibility layer for PromptBox3D's
signal-typed props. Its setters dual-write into the store so any
PromptBox3D-driven changes (grid toggle, aspect ratio dropdown) keep
the store authoritative. editorState / previewSrc / setEditorState
removed entirely -- nothing outside the store needs them anymore.

Net effect on typecheck: 148 -> 147 errors in PageEnigma. Zero new
errors introduced.
2026-04-28 23:05:54 -07:00
Tanish Manku 35aae54d49 fix: let UploadSuccess actually render in upload modals
The auto-success useEffect in UploadModalSplat / UploadModalImage /
UploadModal3D was firing on UploaderStates.success and immediately
calling onClose(), which short-circuited the UploaderModalContent
switch before the UploaderStates.success branch (UploadSuccess) could
render. The OK button on UploadSuccess was effectively dead code.

Move the success-side actions (open the gallery, fire onSuccess, close
the modal) into the UploadSuccess onOk handler. The user now sees the
"upload successful" dialog and can dismiss it intentionally; the
gallery opens at dismissal so they can drag the freshly uploaded asset
onto the canvas.
2026-04-28 22:56:38 -07:00
Tanish Manku 088edd270f UploadModalSplat: open gallery on success to match image / 3D-model upload
Pre-refactor the splat upload modal had two paths: the (developer-only)
local-bytes path that handed the in-memory buffer to a now-deleted
editor.timeline.addLocalSplat, and a real upload path that registered
the file in Storyteller's media library but did nothing else with it.

The local-bytes branch was the only thing letting a freshly uploaded
splat reach the canvas. With it gone, splat upload was a dead end --
the file landed in the user's media library but they had no path back
to the 3D editor.

Align with the existing image / 3D-model upload convention: on
UploaderStates.success, set galleryModalVisibleViewMode and call
onSuccess(selectedCategory). The user can then drag the freshly
uploaded splat from the auto-opened gallery onto the canvas, which
flows through PageEditor's onImageDrop handler and into
actions/addObject.

Other changes:
- onSuccess signature: (ArrayBuffer, boolean) => (FilterEngineCategories)
  to match UploadModalImage / UploadModal3D
- Drop the unused onLocalBytes prop and plumbing in UploadFilesSplat;
  no callsite invoked it. The two existing consumers
  (PageEnigma/Controls3D, GlobalFileDropHandler) ignore the category
  argument, which is fine -- () => void is assignable.
2026-04-28 22:50:17 -07:00
Tanish Manku 61311028e2 PageEnigma refactor Phase 4: build actions/ and rewire add/select/transform flows
Replaces the four-hop signal-stub pattern (drop -> store-only update,
engine never told) with explicit action helpers that mutate the engine
and the store in one step.

actions/:
- addObject / addCharacter / addShape: call editor.sceneManager.create
  to load the THREE.js object, then update the Zustand store. addCharacter
  also stamps userData.isCharacter on the loaded object
- deleteObject: thin wrapper over editor.deleteObject (which already
  goes through SceneUtils to update the store)
- selectObject / deselectObject: invoke sceneManager.select_object;
  Editor.publishSelect already writes the selection to the store
- setTransformMode: drives editor.change_mode + store + the legacy
  selectedMode signal during the migration window
- setCameraAspect: drives editor.changeRenderCameraAspectRatio + store +
  the legacy cameraAspectRatio signal
- index.ts barrel

Wiring updates:
- PageEditor drop handler (gallery -> 3D) now calls actions/addObject
  and actions/addCharacter with the editor and world position
- DragAndDrop/DndAsset takes the editor at pointerdown time and calls
  actions/* on drop. ItemElement passes the editor in via EngineContext
- AspectRatioMenu calls actions/setCameraAspect; PageEditor's bottom-bar
  aspect-ratio select uses the action too
- Controls3D's mode toggle (move/rotate/scale) goes through
  actions/setTransformMode and the four little redundant handlers are
  collapsed into one
- ImageTo3DExperience's "Open in 3D Editor" button uses getActiveEditor
  to pick up the live editor across pages

Plumbing:
- New module-level activeEditor handle in EngineContext (with
  getActiveEditor / setActiveEditor) so non-React callers and cross-page
  actions can reach the engine; EngineProvider sets it on init and
  clears on unmount
- Drop the now-orphaned signals/objectGroup, signals/characterGroups,
  signals/shape entirely. Their job (store update + engine call) lives
  in actions/. signals/index.ts loses the corresponding re-exports
- Remove the placeholder addLocalSplat onSuccess in Controls3D (a
  developer-test path for direct splat loading; the real splat upload
  goes through GlobalFileDropHandler) and delete the now-confirmed-dead
  recordScene method in editor.ts
- video_generation.ts zipBlob: type as Blob | null (matches the
  UploadStudioShot signature; pre-existing TS error)

Net effect on typecheck: 144 -> 147 errors in PageEnigma + experiences;
zero errors introduced in actions/, PageEnigmaStore, or DragAndDrop. All
new errors are pre-existing issues in adjacent files surfaced by the
edits, not regressions.
2026-04-28 22:37:30 -07:00
Tanish Manku 6d22d0d3c3 PageEnigma refactor: strip Queue, TimeLine, and timeline engines from editor.ts
editor.ts surgery (1864 -> 1665 lines):

- Drop imports + field declarations + constructor instantiations for
  AudioEngine, TransformEngine, EmotionEngine, LipSyncEngine,
  CharacterAnimationEngine, and TimeLine; drop the
  Queue/QueueNames/fromEngineActions/toEngineActions imports
- Drop timeline state fields: can_playback, playback_location,
  current_frame, last_scrub. generating_preview stays (it tracks the
  stylize preview render, not the timeline)
- Replace Queue.publish in changeRenderCameraAspectRatio, newScene,
  loadScene, and publishSelect with direct store actions
  (usePageEnigmaStore.getState().setSelectedObject / resetScene)
- Replace timeline.characters lookups (used by SceneManager outliner)
  with a getCharactersByUuid helper that reads the store
- Replace timeline.isCharacter (passed into SceneManager) with an
  isCharacterUuid helper that checks the store's character list
- recordScene becomes a no-op stub: timeline-driven frame capture is
  gone, video generation needs to drive its own frame loop. TODO marker
  for the rewiring
- generateVideo and togglePlayback drop their timeline.is_playing /
  scrubber gating; renderSingleFrame drops the timeline.update branch
- Bonus: kill the meraAspectRatio typo (a pre-existing bug in the
  recordScene branch I deleted)

Cascading fixes:

- save_manager.ts loadScene/loadCache: drop calls to the deleted
  timeline.scrub and timeline.checkEditorCanPlay
- video_generation.ts videoProcessingPipeline: stub the
  engineFrameBuffers.payloadZip path with a TODO; the video upload now
  always errors via the existing handleError. The full rewiring is the
  same TODO as recordScene
- Controls3D UploadModalSplat onSuccess: drop the
  editor.timeline.addLocalSplat call
- SidePanelTabs: drop a long-broken import of useMouseEventsSidePanel
  (the Timeline/utils path it pointed to was already deleted before
  this refactor); resize handle gets a TODO marker for Phase 6

Net effect on typecheck: 162 -> 144 errors in PageEnigma. Every
remaining error in PageEnigma predates this branch; no new TypeScript
errors introduced by the refactor.
2026-04-28 22:23:11 -07:00
Tanish Manku 50e68b0721 PageEnigma refactor: strip Queue and timeline-only signals from comps and engine helpers
Removes references to the deleted Queue/timeline subsystem from:

- Engine helpers: scene_manager_api, video_generation, helper. Their
  Queue.publish calls (UPDATE_CHARACTER_ID/ADD_OBJECT/POP_A_TOAST/
  DELETE_OBJECT) now write to the new Zustand store / addToast directly.
- Editor/EditorState: drop TimelineState class and its JSON variant; trim
  EditorState/EditorStateJson down to just sceneState and metadata.
- Editor/scene.ts: drop the timeline animation-clip seeding path; restore
  placeholder_manager (incorrectly classified as dead in the audit).
- Signals: collapse signals/objectGroup, signals/characterGroups, and
  signals/shape to non-timeline scaffolds that update the store directly
  with a TODO marker for the engine-side rewiring. Drop
  loadObjectData/loadCharacterData/updateCharacters/updateObjectGroup
  files (all timeline-only). Strip dndTimelineHeight + overTimeline from
  signals/dragAndDrop. Add setEditorState helper to signals/engine.
- Comps: drop Queue.publish calls. Aspect-ratio menu and stylize-tab
  state transitions go through signal setters; TOGGLE_CAMERA_STATE,
  REFRESH_PREVIEW, GENERATE_VIDEO are stubbed with TODOs pending the
  editor.ts rewiring. DragComponent simplified (timeline-drop branch was
  unreachable). PageEditor drops timelineHeight/dndTimelineHeight layout
  math.
- PageEnigma drops the useQueueHandler call.

The branch still does not compile: editor.ts (1864 lines) is the next
target -- it instantiates TimeLine + the five timeline engines and
publishes select/aspect-ratio events through the queue. Cleaning it up
is the focused work for the next commit.
2026-04-28 22:07:31 -07:00
Tanish Manku 1351a40b3d PageEnigma refactor checkpoint: delete timeline subsystem and add Zustand store
Phase 1-3 of the 3D page refactor:
- Delete the timeline + event-queue subsystem (Queue/, useQueueHandler,
  Editor/timeline.ts, Editor/Engines/, clips/, and the timeline-only
  signal slices: timeline, timing, audioGroup, cameraGroup,
  promptTravelGroup, utils/publishClip)
- Delete dead-code files with no live importers: lipsync.js,
  retargeting.ts, preview_video_generation.ts, shaders.js,
  placeholder_manager.ts, both composable_engines/*.tsx stubs,
  contexts/TrackContext/, comps/TimerGrid, comps/ControlsVideo
- Add PageEnigmaStore.ts with the full state shape that will replace
  preact-signals (scene objects, cameras, transform mode, layout, error
  overlays, generation/stylize state)
- Strip signals/index.ts of deleted slice exports and reduce
  signals/sizing.ts to just sidePanelWidth

Branch does not yet compile -- subsequent commits will strip
Queue/timeline references from editor.ts, the remaining signal files,
and component consumers, then build out actions/ and event hooks.
2026-04-28 21:56:58 -07:00
Brandon Thomas c1f4ae21e2 discrete apis (#1443) 2026-04-28 20:33:34 -04:00
Brandon Thomas 934ab64eac happy horse access (#1442) 2026-04-28 16:56:42 -04:00
Brandon Thomas badad317b8 retry kinovi polling with backoff (#1441) 2026-04-28 16:49:44 -04:00
bflatastic 7f35eec2b4 fix: guard handleGenerate with useRef to close double-submit race 2026-04-28 22:57:27 +07:00
bflatastic 4cb5a713a8 web credits in navbar 2026-04-28 09:50:23 +07:00
BFlat ae8fd30b4b shift enter to generate by default, toggleable via settings panel in app (#1440)
- Pressing **Enter** in any prompt box now adds a new line instead of
submitting - fixes complaints about accidental generations
- **Shift+Enter** is the new submit shortcut
- Desktop app gets an "Enter to generate" toggle in Settings → General
(off by default) for users who prefer the old behavior
- Applies to all prompt boxes in the Artcraft desktop app and the
website
2026-04-26 21:34:08 +07:00
bflatastic d37a732d60 name mappings for happy horse 1.0 2026-04-24 16:15:17 +07:00
bflatastic 726bde7c11 happy horse alibaba icon 2026-04-24 15:48:11 +07:00
Brandon Thomas 1864eedb7a moderator feature flags editing (#1436) 2026-04-24 03:42:57 -04:00
Brandon Thomas 853f596efc add happyhorse behind feature flag (#1435) 2026-04-24 03:06:35 -04:00
bflatastic 5535d50ec3 website: image ref selection limit fixed, gallery item active focus outline blinking fixed 2026-04-24 13:25:17 +07:00
bflatastic 34cd723417 website: persist references after navigation 2026-04-24 13:11:58 +07:00
Brandon Thomas 681f94b245 fix omni seedance characters (#1434) 2026-04-24 00:04:14 -04:00
Brandon Thomas cfa5b211ea kinovi happyhorse (#1433) 2026-04-23 23:24:07 -04:00
Brandon Thomas 8436d42d41 more logging 2026-04-23 19:25:00 -04:00
Brandon Thomas 53acdd544f omni video handler - utilize second pipeline for seedance; various other fixes (#1432) 2026-04-23 19:21:53 -04:00
bflatastic 5ea6172ce8 website fix: mobile remove bottom overlay on error card blocking dismiss button 2026-04-23 13:46:06 +07:00
BFlat 9166148701 website error messages (#1431)
artcraft website now shows error messages on the failed cards and task
queue
2026-04-23 13:43:17 +07:00
Brandon Thomas e6e739e874 move old artcraft_router pipeline (#1430) 2026-04-23 00:29:22 -04:00
Brandon Thomas 4112652c4e artcraft 0.29 -> 0.30 (#1429) 2026-04-22 22:27:13 -04:00
Brandon Thomas 969d7d7609 add seedance fast @ artcraft to artcraft router, update seedance (non-fast): both call omni (#1428) 2026-04-22 21:34:52 -04:00
Brandon Thomas 1f53b6a4cf tauri: seedance 2.0 fast, seedance 2.0 1080p (#1427) 2026-04-22 19:45:58 -04:00
Brandon Thomas 3947eebe7e bump artcraft 0.28 -> 0.29 (#1426) 2026-04-22 19:18:19 -04:00
Brandon Thomas 5eb2abf112 artcraft desktop gpt-image cost estimates + quality arg (#1425) 2026-04-22 19:17:07 -04:00
Brandon Thomas 31a9704faf fix media file list pagination (#1423) 2026-04-22 17:12:06 -04:00
bflatastic 02676680b1 fixed quality field name 2026-04-23 01:56:45 +07:00
BFlat ba19e9ae40 gpt image 2 quality selector (#1422) 2026-04-23 01:37:50 +07:00
Brandon Thomas 00499252a1 gpt-image-2 desktop support for quality (#1421) 2026-04-22 14:18:55 -04:00
Tanish Manku c7b9de7cf9 3D: Fix editor undo/redo keybinds and hotkey signal bugs (#1252)
- Ctrl+Z/Cmd+Z was calling redo() instead of undo(); fixed and added
missing Ctrl+Shift+Z/Cmd+Shift+Z and Ctrl+Y redo bindings
- Wrap all async keybind handlers in try/finally so isProcessing is
always cleared even if undo/redo/copy/paste throws
- Fix === typo in disableHotkeyInput (comparison instead of assignment),
and use full value replacement so the Preact signal update is reactive
2026-04-22 08:24:42 -07:00
Brandon Thomas c2ca096099 desktop frontend support for gpt-image-2 (#1418) 2026-04-22 10:40:47 -04:00
Brandon Thomas a04ee4f76c fix compile error 2026-04-22 10:15:52 -04:00
bflatastic 8bc552c450 add gpt image 2 to name mapping 2026-04-22 18:44:24 +07:00
BFlat 128d3e43c5 reuse gallery modal for the artcraft website and app styling aligned (#1415)
- website to reuse shared app lib's gallery modal for image selection
- styling alignments with the desktop app
- new checkbox shared component
2026-04-22 18:38:02 +07:00
Brandon Thomas bc226fb361 omni gpt image 2 (#1414)
this will enable it in storyteller-web
2026-04-22 01:11:42 -04:00
Brandon Thomas 7817f3af70 fal gpt image 2 (#1413) 2026-04-21 21:53:14 -04:00
Brandon Thomas cf1f4eb8c8 gpt image 2 enum (#1412) 2026-04-21 21:08:28 -04:00
Brandon Thomas 2e6ccf0d00 artcraft v2 router for seedance 2.0 (non-fast) (#1411) 2026-04-21 20:32:41 -04:00
Brandon Thomas a560541b62 import cleanup 2026-04-21 18:43:20 -04:00
Brandon Thomas df4e300119 artcraft_router v2 kinovi seedance 2 fast (#1410) 2026-04-21 18:34:49 -04:00
Brandon Thomas 9bd3726d7a artcraft router redesign (#1409) 2026-04-21 17:45:54 -04:00
BFlat fc0f4b7f5c fix: prevent character mention from matching wrong character 2026-04-21 23:20:00 +07:00
Brandon Thomas 94fdd6cb72 rename struct to better enable a state machine flow (#1406) 2026-04-20 18:14:24 -04:00
BFlat 0d235bfe27 gallery card action buttons (#1405)
- Quick actions on gallery cards: Recreate, Share, and Download buttons
now appear on hover directly on every image/video card, so users can act
on content without opening the lightbox first.
- Media type and model badges are larger and restyled for better
readability on thumbnails.
- The library page now uses the same card component as the generation
gallery, so any future card improvements land everywhere at once.
- Library grid tightened from up to 6 columns to a cleaner max of 5 per
row.
- Removed the dotted background on the library page so skeletons and
cards sit on a solid surface.
2026-04-20 23:58:24 +07:00
BFlat 6883b64276 website: unified lightbox, recreate, desktop CTA, prompt fixes (#1404)
## Artcraft website: unified lightbox, recreate, desktop CTA, prompt
fixes

**Lightbox**
- Gallery modal and `/media/:id` page now share one details component -
no more drift.
- Author sticks to the top when you scroll; `X` moved to the details
panel; "Show more" prompt toggle added to the media page.
- Shows aspect ratio, resolution, duration, and audio on/off.

**Recreate (new)**
- Button in the lightbox restores prompt, refs (image / end-frame /
video / audio), model, aspect, resolution, duration, audio toggle, and
input mode from a past generation.
- Routes to Create Image vs Create Video based on the saved model class,
not URL guessing.
- Does not auto-generate.

**Desktop CTA**
- Dismissible banner above the promptbox linking to the desktop app.
Hidden on mobile, reappears 24h after dismissal.

**Model names**
- Model selector uses `full_name` from the OmniGen API so new models
show up correctly without a code change.
- Added Seedance 2.0 Fast to the shared model list for the lightbox
details panel.

**Promptbox `@mentions`**
- Triggers correctly after Chinese, punctuation, or emoji - not just
whitespace.
- Case-insensitive: `@image1` highlights as `@Image1`.
2026-04-20 21:20:27 +07:00
Brandon Thomas 2c9a78b802 rename struct 2026-04-19 22:04:43 -04:00
BFlat 6542834336 fix: video promptbox layout bugs (#1403)
fixes:
- prompt box no longer overlaps bottom buttons or wastes space on big
screens
- long unbroken text wraps instead of escaping the box
- resizing the window updates the layout without needing to refocus
2026-04-20 07:36:47 +07:00
bflatastic 6a2719c742 website download link update 2026-04-20 06:57:24 +07:00
Brandon Thomas 38219a4abf link website omni video jobs to ledger entries for refunds (#1402) 2026-04-19 15:03:20 -04:00
Brandon Thomas 3fc002d793 quick refunds hack for kinovi (#1401)
code is actually solid, but the old omni pipeline itself is a hack on
top of an incomplete artcraft_router, so this will be replaced when
we're done.
2026-04-19 13:51:57 -04:00
Brandon Thomas c0c17b27b5 1080p support (quick) (#1400) 2026-04-18 18:15:35 -04:00
Brandon Thomas b68b8f7d71 simplify artcraft_router (#1399) 2026-04-18 08:40:49 -04:00
Brandon Thomas 3d9b4be7ab rename field (#1398) 2026-04-18 08:10:36 -04:00
Brandon Thomas 489390c881 refacor kinovi generate_video request (#1397) 2026-04-18 07:48:52 -04:00
Brandon Thomas f5338611ac seedance 2.0 resolution options (#1396) 2026-04-17 20:51:17 -04:00
Brandon Thomas 11fc5746ec seedance 1080p (#1395) 2026-04-17 20:31:01 -04:00
Tanish Manku 47383daba8 Experimental: Storyboard page (#1394) 2026-04-17 15:32:04 -07:00
Tanish Manku 9166985418 Experimental Menu: Hidden experimental settings menu via 7x version click (#1393) 2026-04-17 14:17:34 -07:00
BFlat 8fc488551f website: bug fixes (#1391)
## Website bug fixes: Seedance Fast + Characters + Toasts

### Create Video page
- Switching between Seedance and Seedance Fast no longer loses your
attached references
- Seedance Fast now supports Characters (was missing from the UI)
- Characters work in both Keyframe and Reference modes
- Fixed a bug where typing `@Bob2` could accidentally attach an older
character named `@Bob`

### Task queue notifications
- No more ghost "job complete" toasts every time you reload the site
- Cleaner wording: "Image creation complete" instead of "Image complete
— Image Generation"
- Character jobs now say "Character" instead of the generic "Task"

### Toast redesign
- Matches the prompt box style instead of the old bright green
- Success = green circle with a white checkmark, error = red circle with
a white exclamation
- Centered on mobile, stays top-right on desktop
- Message no longer awkwardly wraps onto multiple lines on mobile
2026-04-17 15:39:20 +07:00
Brandon Thomas 3a9cebc1b8 omni character support (#1390) 2026-04-17 03:48:35 -04:00
661 changed files with 25907 additions and 21555 deletions
+19 -1
View File
@@ -42,6 +42,21 @@ then the story, then the fine print.
- A method that calls another method in the same file should appear *above* the method
it calls. This lets readers follow the call chain downward.
### Ordering within test modules
Put test cases first, helpers last. A reader opening a test file should immediately see
*what* is being tested before *how* the test machinery works.
1. **Imports**
2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference
3. **Test functions** or **sub-modules** grouped by category
4. **Helper functions** (builders, pipeline runners, assertions)
Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+
tests. Don't create a sub-module for a single test. Keep nesting to two levels max
(`mod tests { mod category { ... } }`). Shared helpers go in the parent module so
sub-modules can reach them via `use super::*`.
## Naming
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
@@ -53,7 +68,10 @@ then the story, then the fine print.
## Imports
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer `use` imports over inline fully-qualified paths. Inline qualification is acceptable
only for genuine one-off references where adding an import would be noise, or for types
that collide with std prelude names (`Result`, `Option`, `Error`). If you reference a symbol
more than once, always import it.
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
- When two crates export a type with the same name, alias with a suffix describing the source crate:
`use enums::...::CommonResolution as CommonResolutionEnum;`
+27 -1
View File
@@ -10,10 +10,28 @@
## Test Patterns
- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file
- Group related tests in sub-modules: `mod explicit_checks`, `mod mechanical_checks`
- Group related tests into sub-modules by category (e.g. `mod pricing_tests`, `mod resolution_tests`)
- Only create a sub-module when it has 2+ tests — don't wrap a single test in its own module
- Keep nesting shallow: `mod tests { mod category { ... } }` is fine; a third level is rarely needed
- Shared helpers used by multiple sub-modules belong in the parent `mod tests`, not duplicated in each sub-module
- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count
- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases
## Test File Layout
Put test cases first, helpers last. Engineers should see the actual tests before scrolling
to understand helper plumbing.
Within a `mod tests { ... }` block:
1. **Imports** at the top
2. **Constants** (test data IDs, URLs, etc.) — small context that tests reference
3. **Test functions** (`#[test]` / `#[tokio::test]`) grouped by category
4. **Helper functions** (builders, pipeline runners, etc.) at the very bottom
If tests are grouped into sub-modules, the same rule applies within each sub-module:
constants first, then tests, then helpers at the end of that sub-module.
## What to Test
- New error types and their mappings
@@ -21,6 +39,14 @@
- Hash/deduplication functions with known inputs
- Edge cases in parsers (empty input, missing fields, malformed data)
## Imports in Tests
- Import test fixtures and constants with `use`, not inline fully-qualified paths.
Write `use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;` at the top,
then reference `JUNO_AT_LAKE_IMAGE_URL` in test bodies.
- Same rule applies to enum variants used in match arms — import them, don't write
`crate::some::deep::module::MyEnum::Variant` inline.
## What Not to Test
- Don't add integration tests that require a running database unless explicitly asked
+2
View File
@@ -62,6 +62,7 @@ artcraft/
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
@@ -75,3 +76,4 @@ Organize for top-to-bottom reading. Important things first, details later.
- **In impl blocks**: constructors first, then public methods, then private helpers
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
@@ -15,11 +15,12 @@ body:
type: json
content: |
{
"model": "seedream_5_lite",
"model": "gpt_image_2",
"provider": "artcraft",
"generation_mode": {"type": "text_to_image" },
"aspect_ratio": "wide_sixteen_by_nine",
"resolution": "two_k",
"quality": "low",
"image_batch_count": 1
}
order: 2000
+59
View File
@@ -0,0 +1,59 @@
# Rust Workspace
All Rust code lives under `crates/`. The workspace is defined in `Cargo.toml` at the repo root.
## Code Style
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`
- When two crates export the same type name, alias with a suffix: `use foo::Bar as BarFoo;`
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std prelude collisions (`Result`, `Option`, `Error`)
## File Layout
Organize for top-to-bottom reading. Important things first, details later.
1. **Constants** at the top (after imports)
2. **Primary public type** (the main struct/enum the file defines)
3. **Supporting types** below the primary type
4. **API types** in order: Request, Response, Error
5. **`impl` blocks** after their type definitions
- Constructors first, then public methods, then private helpers
- Callers above callees — follow the call chain downward
## Test Layout
Within `#[cfg(test)] mod tests { ... }`:
1. **Imports**
2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference
3. **Test functions or sub-modules** grouped by category
4. **Helper functions** (builders, pipeline runners, assertions)
Constants provide small context up front. Test cases come next so engineers see *what* is
tested before *how* the helpers work.
Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+
tests. Don't wrap a single test in its own module. Keep nesting to two levels max. Shared
helpers go in the parent `mod tests` so sub-modules can `use super::*`.
## Building
- Most crates: `cargo check -p {crate_name}` or `cargo test -p {crate_name}`
- Crates using SQLx: `SQLX_OFFLINE=true cargo check -p {crate_name}`
- `storyteller-web` and `mysql_queries` require `SQLX_OFFLINE=true`
- `storyteller-web` has ~370 pre-existing warnings — check the last few lines for actual errors
## Key Crates
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, Seedance2Pro, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service
- `enums` — database-backed enums stored as VARCHAR fields
- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- `mysql_queries` — SQLx MySQL queries for the backend
@@ -3,13 +3,14 @@ use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image";
/// Request body for the image cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct EstimateImageCostRequest {
/// The image model to estimate costs for.
pub model: CommonImageModel,
@@ -29,12 +30,15 @@ pub struct EstimateImageCostRequest {
/// Optional resolution.
pub resolution: Option<CommonResolution>,
// Optional quality.
pub quality: Option<CommonQuality>,
/// Number of images to generate in parallel.
pub image_batch_count: Option<u16>,
}
/// Describes the type of image generation being requested.
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GenerationMode {
/// No input images — generate from prompt only.
@@ -94,6 +98,7 @@ mod tests {
generation_mode: GenerationMode::TextToImage,
aspect_ratio: None,
resolution: None,
quality: None,
image_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
@@ -108,6 +113,7 @@ mod tests {
generation_mode: GenerationMode::ImageEdit { count: 2 },
aspect_ratio: None,
resolution: None,
quality: None,
image_batch_count: None,
};
let serialized = serde_json::to_string(&request).unwrap();
@@ -7,7 +7,7 @@ use utoipa::ToSchema;
pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0";
/// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based).
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
@@ -37,6 +37,9 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Output resolution quality tier (480p, 720p, 1080p).
pub output_resolution: Option<Seedance2p0OutputResolution>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
@@ -67,6 +70,17 @@ pub enum Seedance2p0BatchCount {
Four,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0OutputResolution {
/// 480p
FourEightyP,
/// 720p (default)
SevenTwentyP,
/// 1080p
TenEightyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
@@ -9,7 +9,7 @@ use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the video cost estimate and video generation endpoints.
#[derive(Serialize, Deserialize, ToSchema, Debug)]
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenVideoCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
@@ -0,0 +1 @@
pub mod omni_gen_image;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
pub const OMNI_GEN_IMAGE_GENERATE_PATH: &str = "/v1/omni_gen/generate/image";
pub async fn omni_gen_image_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenImageCostAndGenerateRequest,
) -> Result<OmniGenImageGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_IMAGE_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,2 @@
pub mod image;
pub mod video;
@@ -17,6 +17,8 @@ pub enum CommonImageModel {
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "nano_banana")]
NanoBanana,
#[serde(rename = "nano_banana_2")]
@@ -41,6 +41,9 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "sora_2")]
Sora2,
@@ -15,6 +15,9 @@ pub enum ArtcraftRouterError {
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
/// [Temporary during migration] The requested model/provider is not yet supported by the router.
UnsupportedProviderAndModelForNewApi(String),
/// Invalid or missing input arguments.
InvalidInput(String),
@@ -30,6 +33,7 @@ impl Display for ArtcraftRouterError {
Self::Client(e) => write!(f, "Client error: {}", e),
Self::Download(e) => write!(f, "Download error: {}", e),
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
Self::UnsupportedProviderAndModelForNewApi(msg) => write!(f, "Unsupported provider/model (for new API during migration): {}", msg),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Provider(e) => write!(f, "Provider error: {}", e),
}
@@ -1,5 +1,7 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
@@ -22,6 +24,9 @@ impl Display for ClientType {
#[derive(Debug)]
pub enum ClientError {
/// A RouterClient was required but not provided on the draft context.
RouterClientNotProvided,
/// The requested client is not configured on the RouterClient.
ClientNotConfigured(ClientType),
@@ -43,6 +48,18 @@ pub enum ClientError {
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
/// The pre-dispatch context of media file token to URL map was not supplied.
MediaFileToUrlMapNotProvided,
/// A media file token was not found in the provided media-file-to-URL map.
MediaFileTokenNotFoundInMap { token: MediaFileToken },
/// The pre-dispatch context of character token to ID map was not supplied.
CharacterTokenToKinoviCharacterIdNotProvided,
/// A character token was not found in the provided character-token-to-id map
CharacterTokenNotFoundInMap { token: CharacterToken },
}
impl Error for ClientError {}
@@ -50,6 +67,9 @@ impl Error for ClientError {}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::RouterClientNotProvided => {
write!(f, "A RouterClient is required but was not provided on the draft context")
}
Self::ClientNotConfigured(client_type) => {
write!(f, "{} client is not configured on the RouterClient", client_type)
}
@@ -71,6 +91,18 @@ impl Display for ClientError {
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
Self::MediaFileToUrlMapNotProvided => {
write!(f, "Media file to URL map was not provided")
}
Self::MediaFileTokenNotFoundInMap { token } => {
write!(f, "Media file token '{}' was not found in the provided URL map", token.as_str())
}
Self::CharacterTokenToKinoviCharacterIdNotProvided => {
write!(f, "Character token to Kinovi character ID map was not provided")
}
Self::CharacterTokenNotFoundInMap { token } => {
write!(f, "Character token '{}' was not found in the provided character-token-to-id map", token.as_str())
}
}
}
}
@@ -0,0 +1,210 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::{
ArtcraftGptImage2ImageSize, ArtcraftGptImage2Quality, PlanArtcraftGptImage2,
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_2(
plan: &PlanArtcraftGptImage2,
) -> ImageGenerationCostEstimate {
// Same cost table as the Fal GPT Image 2 estimator.
//
// Output image cost (per output image), rounded up to whole cents:
// Low: all sizes → 1¢
// Medium: landscape_4_3/portrait_4_3 → 4¢
// landscape_16_9/portrait_16_9 → 4¢
// square → 6¢
// square_hd → 6¢
// High: landscape_4_3/portrait_4_3 → 15¢
// landscape_16_9/portrait_16_9 → 16¢
// square → 22¢
// square_hd → 23¢
//
use ArtcraftGptImage2ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(ArtcraftGptImage2Quality::Low, _) => 1,
// Medium quality
(ArtcraftGptImage2Quality::Medium, None | Some(S::Square) | Some(S::SquareHd)) => 6,
(ArtcraftGptImage2Quality::Medium, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 4,
(ArtcraftGptImage2Quality::Medium, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 4,
(ArtcraftGptImage2Quality::Medium, Some(S::Auto)) => 6,
// High quality
(ArtcraftGptImage2Quality::High, None | Some(S::Square)) => 22,
(ArtcraftGptImage2Quality::High, Some(S::SquareHd)) => 23,
(ArtcraftGptImage2Quality::High, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 15,
(ArtcraftGptImage2Quality::High, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 16,
(ArtcraftGptImage2Quality::High, Some(S::Auto)) => 23,
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::ArtcraftGptImage2NumImages;
fn plan(
quality: ArtcraftGptImage2Quality,
image_size: Option<ArtcraftGptImage2ImageSize>,
num_images: ArtcraftGptImage2NumImages,
) -> PlanArtcraftGptImage2 {
PlanArtcraftGptImage2 {
prompt: Some("p".to_string()),
image_inputs: None,
image_size,
quality,
num_images,
idempotency_token: "test".to_string(),
}
}
fn cents(
quality: ArtcraftGptImage2Quality,
image_size: Option<ArtcraftGptImage2ImageSize>,
num_images: ArtcraftGptImage2NumImages,
) -> u64 {
estimate_image_cost_artcraft_gpt_image_2(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// -- Low quality (1c for all sizes) -------------------------------------------
#[test]
fn low_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, None, ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, None, ArtcraftGptImage2NumImages::Four), 4);
}
// -- Medium quality -----------------------------------------------------------
#[test]
fn medium_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, None, ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Portrait4x3), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_landscape_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Landscape16x9), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Portrait16x9), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::Four), 24);
}
// -- High quality -------------------------------------------------------------
#[test]
fn high_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 22);
}
#[test]
fn high_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 23);
}
#[test]
fn high_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, None, ArtcraftGptImage2NumImages::One), 22);
}
#[test]
fn high_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 15);
}
#[test]
fn high_portrait_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Portrait4x3), ArtcraftGptImage2NumImages::One), 15);
}
#[test]
fn high_landscape_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Landscape16x9), ArtcraftGptImage2NumImages::One), 16);
}
#[test]
fn high_portrait_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Portrait16x9), ArtcraftGptImage2NumImages::One), 16);
}
#[test]
fn high_square_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::Four), 88);
}
#[test]
fn high_square_hd_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::Four), 92);
}
// -- Metadata flags -----------------------------------------------------------
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_artcraft_gpt_image_2(&plan(
ArtcraftGptImage2Quality::Medium, None, ArtcraftGptImage2NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -4,6 +4,7 @@ pub mod estimate_image_cost_artcraft_flux_pro_1p1;
pub mod estimate_image_cost_artcraft_flux_pro_1p1_ultra;
pub mod estimate_image_cost_artcraft_gpt_image_1;
pub mod estimate_image_cost_artcraft_gpt_image_1p5;
pub mod estimate_image_cost_artcraft_gpt_image_2;
pub mod estimate_image_cost_artcraft_nano_banana;
pub mod estimate_image_cost_artcraft_nano_banana_2;
pub mod estimate_image_cost_artcraft_nano_banana_pro;
@@ -0,0 +1,209 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::{
FalGptImage2ImageSize, FalGptImage2Quality, PlanFalGptImage2,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_2(
plan: &PlanFalGptImage2,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-2 and fal-ai/gpt-image-2/edit):
//
// Output image cost (per output image), rounded up to whole cents:
// Low: all sizes → 1¢
// Medium: landscape_4_3/portrait_4_3 → 4¢
// landscape_16_9/portrait_16_9 → 4¢
// square → 6¢
// square_hd → 6¢
// High: landscape_4_3/portrait_4_3 → 15¢
// landscape_16_9/portrait_16_9 → 16¢
// square → 22¢
// square_hd → 23¢
//
use FalGptImage2ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(FalGptImage2Quality::Low, _) => 1,
// Medium quality
(FalGptImage2Quality::Medium, None | Some(S::Square) | Some(S::SquareHd)) => 6,
(FalGptImage2Quality::Medium, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 4,
(FalGptImage2Quality::Medium, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 4,
(FalGptImage2Quality::Medium, Some(S::Auto)) => 6, // TODO: Not checked
// High quality
(FalGptImage2Quality::High, None | Some(S::Square)) => 22,
(FalGptImage2Quality::High, Some(S::SquareHd)) => 23,
(FalGptImage2Quality::High, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 15,
(FalGptImage2Quality::High, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 16,
(FalGptImage2Quality::High, Some(S::Auto)) => 23, // TODO: Not checked
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::FalGptImage2NumImages;
fn plan(
quality: FalGptImage2Quality,
image_size: Option<FalGptImage2ImageSize>,
num_images: FalGptImage2NumImages,
) -> PlanFalGptImage2 {
PlanFalGptImage2 {
prompt: Some("p".to_string()),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn cents(
quality: FalGptImage2Quality,
image_size: Option<FalGptImage2ImageSize>,
num_images: FalGptImage2NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_2(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// -- Low quality (1c for all sizes) -------------------------------------------
#[test]
fn low_square() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_square_hd() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(FalGptImage2Quality::Low, None, FalGptImage2NumImages::One), 1);
}
#[test]
fn low_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_four() {
assert_eq!(cents(FalGptImage2Quality::Low, None, FalGptImage2NumImages::Four), 4);
}
// -- Medium quality -----------------------------------------------------------
#[test]
fn medium_square() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_square_hd() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_unset() {
assert_eq!(cents(FalGptImage2Quality::Medium, None, FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_4x3() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Portrait4x3), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_landscape_16x9() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Landscape16x9), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_16x9() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Portrait16x9), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::Four), 24);
}
// -- High quality -------------------------------------------------------------
#[test]
fn high_square() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 22);
}
#[test]
fn high_square_hd() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 23);
}
#[test]
fn high_unset() {
assert_eq!(cents(FalGptImage2Quality::High, None, FalGptImage2NumImages::One), 22);
}
#[test]
fn high_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 15);
}
#[test]
fn high_portrait_4x3() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Portrait4x3), FalGptImage2NumImages::One), 15);
}
#[test]
fn high_landscape_16x9() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Landscape16x9), FalGptImage2NumImages::One), 16);
}
#[test]
fn high_portrait_16x9() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Portrait16x9), FalGptImage2NumImages::One), 16);
}
#[test]
fn high_square_four() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::Four), 88);
}
#[test]
fn high_square_hd_four() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::Four), 92);
}
// -- Metadata flags -----------------------------------------------------------
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_2(&plan(
FalGptImage2Quality::Medium, None, FalGptImage2NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -4,6 +4,7 @@ pub(crate) mod estimate_image_cost_fal_flux_pro_1p1;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1_ultra;
pub(crate) mod estimate_image_cost_fal_gpt_image_1;
pub(crate) mod estimate_image_cost_fal_gpt_image_1p5;
pub(crate) mod estimate_image_cost_fal_gpt_image_2;
pub(crate) mod estimate_image_cost_fal_seedream_4;
pub(crate) mod estimate_image_cost_fal_seedream_4p5;
pub(crate) mod estimate_image_cost_fal_seedream_5_lite;
@@ -0,0 +1,145 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::PlanArtcraftGptImage2;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate;
use enums::common::generation::common_image_model::CommonImageModel;
/// Execute GPT Image 2 via the Artcraft omni-gen image endpoint.
pub async fn execute_artcraft_gpt_image_2(
plan: &PlanArtcraftGptImage2,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = OmniGenImageCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonImageModel::GptImage2),
prompt: plan.prompt.clone(),
image_media_tokens: plan.image_inputs.clone(),
resolution: None,
aspect_ratio: plan.aspect_ratio_enum(),
quality: Some(plan.quality_enum()),
image_batch_count: Some(plan.num_images_u16()),
adjust_horizontal_angle: None,
adjust_vertical_angle: None,
adjust_zoom: None,
};
let response = omni_gen_image_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use artcraft_client::utils::api_host::ApiHost;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{get_artcraft_client, get_artcraft_client_for_env};
use tokens::tokens::media_files::MediaFileToken;
fn base_gpt_image_2_request() -> GenerateImageRequest {
GenerateImageRequest {
model: crate::api::common_image_model::CommonImageModel::GptImage2,
provider: crate::api::provider::Provider::Artcraft,
prompt: Some("a t-rex walking through a cyberpunk city at night".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_2() {
let client = get_artcraft_client_for_env(ApiHost::Localhost { port: 12345 });
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_gpt_image_2() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_2_high_quality() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: Some(CommonQuality::High),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -4,6 +4,7 @@ pub mod generate_image_artcraft_flux_pro_1p1;
pub mod generate_image_artcraft_flux_pro_1p1_ultra;
pub mod generate_image_artcraft_gpt_image_1;
pub mod generate_image_artcraft_gpt_image_1p5;
pub mod generate_image_artcraft_gpt_image_2;
pub mod generate_image_artcraft_nano_banana;
pub mod generate_image_artcraft_nano_banana_2;
pub mod generate_image_artcraft_nano_banana_pro;
@@ -0,0 +1,175 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::PlanFalGptImage2;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::{
enqueue_gpt_image_2_edit_image_webhook, EnqueueGptImage2EditImageArgs,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_2_text_to_image_webhook::{
enqueue_gpt_image_2_text_to_image_webhook, EnqueueGptImage2TextToImageArgs,
};
pub async fn execute_fal_gpt_image_2(
plan: &PlanFalGptImage2,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage2TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
quality: Some(plan.quality.to_t2i()),
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_2_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
let args = EnqueueGptImage2EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_url: None,
image_size: plan.image_size.map(|s| s.to_edit()),
quality: Some(plan.quality.to_edit()),
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_2_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
#[cfg(test)]
mod tests {
use test_data::web::image_urls::{GHOST_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, TREX_SKELETON_IMAGE_URL};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::get_fal_client;
// ── Build smoke tests ──
mod build_smoke_tests {
use super::*;
#[test]
fn text_to_image_plan() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage2(plan) = plan else {
panic!("expected FalGptImage2")
};
assert!(plan.image_urls.is_empty(), "text mode must have no image_urls");
}
#[test]
fn edit_image_plan() {
let urls = vec![
JUNO_AT_LAKE_IMAGE_URL.to_string(),
GHOST_IMAGE_URL.to_string(),
];
let request = GenerateImageRequest {
prompt: Some("dog running from a scary ghost".to_string()),
image_inputs: Some(ImageListRef::Urls(urls.clone())),
..base_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage2(plan) = plan else {
panic!("expected FalGptImage2")
};
assert_eq!(plan.image_urls, urls);
}
}
// ── Live API tests ──
mod live_api_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn text_to_image() {
let client = get_fal_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night".to_string()),
..base_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn edit_image() {
let client = get_fal_client();
let request = GenerateImageRequest {
prompt: Some("dog running from a scary ghost".to_string()),
image_inputs: Some(ImageListRef::Urls(vec![
JUNO_AT_LAKE_IMAGE_URL.to_string(),
GHOST_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn base_request() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage2,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
}
@@ -4,6 +4,7 @@ pub mod generate_image_fal_flux_pro_1p1;
pub mod generate_image_fal_flux_pro_1p1_ultra;
pub mod generate_image_fal_gpt_image_1;
pub mod generate_image_fal_gpt_image_1p5;
pub mod generate_image_fal_gpt_image_2;
pub mod generate_image_fal_seedream_4;
pub mod generate_image_fal_seedream_4p5;
pub mod generate_image_fal_seedream_5_lite;
@@ -16,6 +16,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::plan_generate_image_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::plan_generate_image_artcraft_gpt_image_1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::plan_generate_image_artcraft_gpt_image_1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::plan_generate_image_artcraft_gpt_image_2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::plan_generate_image_artcraft_nano_banana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::plan_generate_image_artcraft_nano_banana_2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::plan_generate_image_artcraft_nano_banana_pro;
@@ -29,6 +30,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::plan_generate_image_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::plan_generate_image_fal_gpt_image_1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::plan_generate_image_fal_gpt_image_1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::plan_generate_image_fal_gpt_image_2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::plan_generate_image_fal_seedream_4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::plan_generate_image_fal_seedream_4p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::plan_generate_image_fal_seedream_5_lite;
@@ -100,18 +102,20 @@ impl GenerateImageRequest {
match self.model {
CommonImageModel::Flux1Dev => plan_generate_image_artcraft_flux_1_dev(self),
CommonImageModel::Flux1Schnell => plan_generate_image_artcraft_flux_1_schnell(self),
CommonImageModel::Flux2LoraAngles => plan_generate_image_artcraft_flux_2_lora_angles(self),
CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_artcraft_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self),
CommonImageModel::GptImage2 => plan_generate_image_artcraft_gpt_image_2(self),
CommonImageModel::NanoBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
CommonImageModel::NanoBananaPro => plan_generate_image_artcraft_nano_banana_pro(self),
CommonImageModel::QwenEdit2511Angles => plan_generate_image_artcraft_qwen_edit_2511_angles(self),
CommonImageModel::Seedream4 => plan_generate_image_artcraft_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_artcraft_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_artcraft_seedream_5_lite(self),
CommonImageModel::QwenEdit2511Angles => plan_generate_image_artcraft_qwen_edit_2511_angles(self),
CommonImageModel::Flux2LoraAngles => plan_generate_image_artcraft_flux_2_lora_angles(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
@@ -123,12 +127,13 @@ impl GenerateImageRequest {
CommonImageModel::FluxPro11Ultra => plan_generate_image_fal_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_fal_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_fal_gpt_image_1p5(self),
CommonImageModel::Seedream4 => plan_generate_image_fal_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_fal_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_fal_seedream_5_lite(self),
CommonImageModel::GptImage2 => plan_generate_image_fal_gpt_image_2(self),
CommonImageModel::NanoBanana => plan_generate_image_fal_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_fal_nano_banana_2(self),
CommonImageModel::NanoBananaPro => plan_generate_image_fal_nano_banana_pro(self),
CommonImageModel::Seedream4 => plan_generate_image_fal_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_fal_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_fal_seedream_5_lite(self),
_ => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
@@ -6,6 +6,7 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_pro_1p1_ultra::estimate_image_cost_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1::estimate_image_cost_artcraft_gpt_image_1;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1p5::estimate_image_cost_artcraft_gpt_image_1p5;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_2::estimate_image_cost_artcraft_gpt_image_2;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana::estimate_image_cost_artcraft_nano_banana;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_2::estimate_image_cost_artcraft_nano_banana_2;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_pro::estimate_image_cost_artcraft_nano_banana_pro;
@@ -20,6 +21,7 @@ use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro_1p1_ultra::estimate_image_cost_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1::estimate_image_cost_fal_gpt_image_1;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1p5::estimate_image_cost_fal_gpt_image_1p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_2::estimate_image_cost_fal_gpt_image_2;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4::estimate_image_cost_fal_seedream_4;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4p5::estimate_image_cost_fal_seedream_4p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_5_lite::estimate_image_cost_fal_seedream_5_lite;
@@ -32,6 +34,7 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_pro_1p1_ultra::execute_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1::execute_artcraft_gpt_image_1;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1p5::execute_artcraft_gpt_image_1p5;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_2::execute_artcraft_gpt_image_2;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana::execute_artcraft_nano_banana;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana_2::execute_artcraft_nano_banana_2;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana_pro::execute_artcraft_nano_banana_pro;
@@ -46,6 +49,7 @@ use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1p1_ultra::execute_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1::execute_fal_gpt_image_1;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1p5::execute_fal_gpt_image_1p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_2::execute_fal_gpt_image_2;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4::execute_fal_seedream_4;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4p5::execute_fal_seedream_4p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_5_lite::execute_fal_seedream_5_lite;
@@ -60,6 +64,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::PlanArtcraftFluxPro11Ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::PlanArtcraftGptImage1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::PlanArtcraftGptImage1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::PlanArtcraftGptImage2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::PlanArtcraftNanaBanana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::PlanArtcraftNanaBanana2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::PlanArtcraftNanaBananaPro;
@@ -74,6 +79,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::PlanFalGptImage1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::PlanFalGptImage2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::PlanFalSeedream5Lite;
@@ -89,6 +95,7 @@ pub enum ImageGenerationPlan {
ArtcraftFluxPro11Ultra(PlanArtcraftFluxPro11Ultra),
ArtcraftGptImage1(PlanArtcraftGptImage1),
ArtcraftGptImage1p5(PlanArtcraftGptImage1p5),
ArtcraftGptImage2(PlanArtcraftGptImage2),
ArtcraftNanaBanana(PlanArtcraftNanaBanana),
ArtcraftNanaBanana2(PlanArtcraftNanaBanana2),
ArtcraftNanaBananaPro(PlanArtcraftNanaBananaPro),
@@ -103,6 +110,7 @@ pub enum ImageGenerationPlan {
FalFluxPro11Ultra(PlanFalFluxPro11Ultra),
FalGptImage1(PlanFalGptImage1),
FalGptImage1p5(PlanFalGptImage1p5),
FalGptImage2(PlanFalGptImage2),
FalSeedream4(PlanFalSeedream4),
FalSeedream4p5(PlanFalSeedream4p5),
FalSeedream5Lite(PlanFalSeedream5Lite),
@@ -141,6 +149,10 @@ impl ImageGenerationPlan {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1p5(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftGptImage2(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_2(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftNanaBanana(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_nano_banana(plan, artcraft_client).await
@@ -197,6 +209,10 @@ impl ImageGenerationPlan {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_1p5(plan, fal_client).await
}
ImageGenerationPlan::FalGptImage2(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_2(plan, fal_client).await
}
ImageGenerationPlan::FalSeedream4(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_seedream_4(plan, fal_client).await
@@ -244,6 +260,9 @@ impl ImageGenerationPlan {
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
estimate_image_cost_artcraft_gpt_image_1p5(plan)
}
ImageGenerationPlan::ArtcraftGptImage2(plan) => {
estimate_image_cost_artcraft_gpt_image_2(plan)
}
ImageGenerationPlan::ArtcraftNanaBanana(plan) => {
estimate_image_cost_artcraft_nano_banana(plan)
}
@@ -286,6 +305,9 @@ impl ImageGenerationPlan {
ImageGenerationPlan::FalGptImage1p5(plan) => {
estimate_image_cost_fal_gpt_image_1p5(plan)
}
ImageGenerationPlan::FalGptImage2(plan) => {
estimate_image_cost_fal_gpt_image_2(plan)
}
ImageGenerationPlan::FalSeedream4(plan) => {
estimate_image_cost_fal_seedream_4(plan)
}
@@ -4,6 +4,7 @@ pub mod plan_generate_image_artcraft_flux_pro_1p1;
pub mod plan_generate_image_artcraft_flux_pro_1p1_ultra;
pub mod plan_generate_image_artcraft_gpt_image_1;
pub mod plan_generate_image_artcraft_gpt_image_1p5;
pub mod plan_generate_image_artcraft_gpt_image_2;
pub mod plan_generate_image_artcraft_nano_banana;
pub mod plan_generate_image_artcraft_nano_banana_2;
pub mod plan_generate_image_artcraft_nano_banana_pro;
@@ -0,0 +1,435 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum;
use tokens::tokens::media_files::MediaFileToken;
/// GPT Image 2 supports six image sizes.
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage2ImageSize {
SquareHd,
Square,
Portrait4x3,
Portrait16x9,
Landscape4x3,
Landscape16x9,
Auto,
}
/// Quality: Low / Medium / High.
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage2Quality {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage2NumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanArtcraftGptImage2 {
pub prompt: Option<String>,
pub image_inputs: Option<Vec<MediaFileToken>>,
pub image_size: Option<ArtcraftGptImage2ImageSize>,
pub quality: ArtcraftGptImage2Quality,
pub num_images: ArtcraftGptImage2NumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_gpt_image_2(
request: &GenerateImageRequest,
) -> Result<ImageGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_inputs = resolve_image_list_ref(request.image_inputs.clone())?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::ArtcraftGptImage2(PlanArtcraftGptImage2 {
prompt: request.prompt.clone(),
image_inputs,
image_size,
quality,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
}
impl PlanArtcraftGptImage2 {
/// Map the plan's aspect ratio to the `CommonAspectRatioEnum` used by the
/// omni-gen request.
pub fn aspect_ratio_enum(&self) -> Option<CommonAspectRatioEnum> {
self.image_size.map(|s| match s {
ArtcraftGptImage2ImageSize::SquareHd => CommonAspectRatioEnum::SquareHd,
ArtcraftGptImage2ImageSize::Square => CommonAspectRatioEnum::Square,
ArtcraftGptImage2ImageSize::Portrait4x3 => CommonAspectRatioEnum::TallThreeByFour,
ArtcraftGptImage2ImageSize::Portrait16x9 => CommonAspectRatioEnum::TallNineBySixteen,
ArtcraftGptImage2ImageSize::Landscape4x3 => CommonAspectRatioEnum::WideFourByThree,
ArtcraftGptImage2ImageSize::Landscape16x9 => CommonAspectRatioEnum::WideSixteenByNine,
ArtcraftGptImage2ImageSize::Auto => CommonAspectRatioEnum::Auto,
})
}
/// Map the plan's quality to the `CommonQualityEnum` used by the omni-gen
/// request.
pub fn quality_enum(&self) -> CommonQualityEnum {
match self.quality {
ArtcraftGptImage2Quality::Low => CommonQualityEnum::Low,
ArtcraftGptImage2Quality::Medium => CommonQualityEnum::Medium,
ArtcraftGptImage2Quality::High => CommonQualityEnum::High,
}
}
pub fn num_images_u16(&self) -> u16 {
match self.num_images {
ArtcraftGptImage2NumImages::One => 1,
ArtcraftGptImage2NumImages::Two => 2,
ArtcraftGptImage2NumImages::Three => 3,
ArtcraftGptImage2NumImages::Four => 4,
}
}
}
impl ArtcraftGptImage2NumImages {
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
fn plan_quality(quality: Option<CommonQuality>) -> ArtcraftGptImage2Quality {
match quality {
Some(CommonQuality::Low) => ArtcraftGptImage2Quality::Low,
Some(CommonQuality::Medium) => ArtcraftGptImage2Quality::Medium,
Some(CommonQuality::High) => ArtcraftGptImage2Quality::High,
None => ArtcraftGptImage2Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<ArtcraftGptImage2ImageSize> {
use ArtcraftGptImage2ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Some(S::Auto),
Some(CommonAspectRatio::Square) => Some(S::Square),
Some(CommonAspectRatio::SquareHd) => Some(S::SquareHd),
Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour) => Some(S::Landscape4x3),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Landscape16x9),
Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive) => Some(S::Portrait4x3),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Portrait16x9),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<ArtcraftGptImage2NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(ArtcraftGptImage2NumImages::One),
2 => Ok(ArtcraftGptImage2NumImages::Two),
3 => Ok(ArtcraftGptImage2NumImages::Three),
4 => Ok(ArtcraftGptImage2NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(ArtcraftGptImage2NumImages::Four),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
// ── Mode detection ──
mod mode_detection {
use super::*;
#[test]
fn no_image_inputs_yields_none() {
let plan = build_plan(&GenerateImageRequest { image_inputs: None, ..base_request() });
assert!(plan.image_inputs.is_none());
}
#[test]
fn media_token_inputs_are_extracted() {
let tokens = vec![
MediaFileToken::new_from_str("mf_test_a"),
MediaFileToken::new_from_str("mf_test_b"),
];
let plan = build_plan(&GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
..base_request()
});
assert!(plan.image_inputs.is_some());
assert_eq!(plan.image_inputs.unwrap().len(), 2);
}
#[test]
fn url_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/a.jpg".to_string()];
let plan = build_plan(&GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_request()
});
assert!(plan.image_inputs.is_none());
}
}
// ── Quality ──
mod quality_tests {
use super::*;
#[test]
fn defaults_to_high() {
let plan = build_plan(&base_request());
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::High));
}
#[test]
fn low() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Low), ..base_request() });
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::Low));
}
#[test]
fn medium() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Medium), ..base_request() });
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::Medium));
}
#[test]
fn high() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::High), ..base_request() });
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::High));
}
}
// ── Image size mapping ──
mod image_size_tests {
use super::*;
#[test]
fn none_yields_auto() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: None, ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Auto)));
}
#[test]
fn auto_variants_yield_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Auto)), "expected Auto for {:?}", ar);
}
}
#[test]
fn square() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::Square), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Square)));
}
#[test]
fn square_hd() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::SquareHd), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::SquareHd)));
}
#[test]
fn landscape_4x3_variants() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::WideFiveByFour] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Landscape4x3)), "expected Landscape4x3 for {:?}", ar);
}
}
#[test]
fn landscape_16x9_variants() {
for ar in [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Landscape16x9)), "expected Landscape16x9 for {:?}", ar);
}
}
#[test]
fn portrait_4x3_variants() {
for ar in [CommonAspectRatio::TallThreeByFour, CommonAspectRatio::TallFourByFive] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Portrait4x3)), "expected Portrait4x3 for {:?}", ar);
}
}
#[test]
fn portrait_16x9_variants() {
for ar in [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Portrait16x9)), "expected Portrait16x9 for {:?}", ar);
}
}
}
// ── Num images ──
mod num_images_tests {
use super::*;
#[test]
fn default_is_one() {
let plan = build_plan(&GenerateImageRequest { image_batch_count: None, ..base_request() });
assert!(matches!(plan.num_images, ArtcraftGptImage2NumImages::One));
}
#[test]
fn direct_mapping() {
for (count, expected) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let plan = build_plan(&GenerateImageRequest { image_batch_count: Some(count), ..base_request() });
assert_eq!(plan.num_images.as_u64(), expected);
}
}
#[test]
fn zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let result = plan_generate_image_artcraft_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))));
}
}
#[test]
fn out_of_range_error_out() {
let result = plan_generate_image_artcraft_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))));
}
#[test]
fn out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let plan = build_plan(&GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(plan.num_images, ArtcraftGptImage2NumImages::Four));
}
}
}
// ── as_u64 ──
#[test]
fn num_images_as_u64() {
assert_eq!(ArtcraftGptImage2NumImages::One.as_u64(), 1);
assert_eq!(ArtcraftGptImage2NumImages::Two.as_u64(), 2);
assert_eq!(ArtcraftGptImage2NumImages::Three.as_u64(), 3);
assert_eq!(ArtcraftGptImage2NumImages::Four.as_u64(), 4);
}
// ── Helpers ──
fn base_request() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage2,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn build_plan(request: &GenerateImageRequest) -> PlanArtcraftGptImage2 {
let ImageGenerationPlan::ArtcraftGptImage2(plan) =
plan_generate_image_artcraft_gpt_image_2(request).expect("plan should succeed")
else {
panic!("expected ArtcraftGptImage2 variant")
};
plan
}
}
@@ -4,6 +4,7 @@ pub mod plan_generate_image_fal_flux_pro_1p1;
pub mod plan_generate_image_fal_flux_pro_1p1_ultra;
pub mod plan_generate_image_fal_gpt_image_1;
pub mod plan_generate_image_fal_gpt_image_1p5;
pub mod plan_generate_image_fal_gpt_image_2;
pub mod plan_generate_image_fal_seedream_4;
pub mod plan_generate_image_fal_seedream_4p5;
pub mod plan_generate_image_fal_seedream_5_lite;
@@ -0,0 +1,535 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::{
EnqueueGptImage2EditImageNumImages, EnqueueGptImage2EditImageQuality,
EnqueueGptImage2EditImageSize,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_2_text_to_image_webhook::{
EnqueueGptImage2TextToImageNumImages, EnqueueGptImage2TextToImageQuality,
EnqueueGptImage2TextToImageSize,
};
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage2NumImages {
One,
Two,
Three,
Four,
}
/// Quality is shared between t2i and edit (Low / Medium / High).
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage2Quality {
Low,
Medium,
High,
}
/// GPT Image 2 supports six image sizes.
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage2ImageSize {
SquareHd,
Square,
Portrait4x3,
Portrait16x9,
Landscape4x3,
Landscape16x9,
Auto,
}
#[derive(Debug, Clone)]
pub struct PlanFalGptImage2 {
pub prompt: Option<String>,
pub image_urls: Vec<String>,
pub image_size: Option<FalGptImage2ImageSize>,
pub quality: FalGptImage2Quality,
pub num_images: FalGptImage2NumImages,
}
pub fn plan_generate_image_fal_gpt_image_2(
request: &GenerateImageRequest,
) -> Result<ImageGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_urls = resolve_image_list_ref(request.image_inputs.clone())?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalGptImage2(PlanFalGptImage2 {
prompt: request.prompt.clone(),
image_urls,
image_size,
quality,
num_images,
}))
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls.clone()),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_quality(quality: Option<CommonQuality>) -> FalGptImage2Quality {
match quality {
Some(CommonQuality::Low) => FalGptImage2Quality::Low,
Some(CommonQuality::Medium) => FalGptImage2Quality::Medium,
Some(CommonQuality::High) => FalGptImage2Quality::High,
None => FalGptImage2Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<FalGptImage2ImageSize> {
use FalGptImage2ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Some(S::Auto),
Some(CommonAspectRatio::Square) => Some(S::Square),
Some(CommonAspectRatio::SquareHd) => Some(S::SquareHd),
Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour) => Some(S::Landscape4x3),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Landscape16x9),
Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive) => Some(S::Portrait4x3),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Portrait16x9),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalGptImage2NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalGptImage2NumImages::One),
2 => Ok(FalGptImage2NumImages::Two),
3 => Ok(FalGptImage2NumImages::Three),
4 => Ok(FalGptImage2NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(FalGptImage2NumImages::Four),
},
}
}
impl FalGptImage2NumImages {
pub fn to_t2i(self) -> EnqueueGptImage2TextToImageNumImages {
use EnqueueGptImage2TextToImageNumImages as T;
match self {
Self::One => T::One,
Self::Two => T::Two,
Self::Three => T::Three,
Self::Four => T::Four,
}
}
pub fn to_edit(self) -> EnqueueGptImage2EditImageNumImages {
use EnqueueGptImage2EditImageNumImages as E;
match self {
Self::One => E::One,
Self::Two => E::Two,
Self::Three => E::Three,
Self::Four => E::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
impl FalGptImage2Quality {
pub fn to_t2i(self) -> EnqueueGptImage2TextToImageQuality {
use EnqueueGptImage2TextToImageQuality as T;
match self {
Self::Low => T::Low,
Self::Medium => T::Medium,
Self::High => T::High,
}
}
pub fn to_edit(self) -> EnqueueGptImage2EditImageQuality {
use EnqueueGptImage2EditImageQuality as E;
match self {
Self::Low => E::Low,
Self::Medium => E::Medium,
Self::High => E::High,
}
}
}
impl FalGptImage2ImageSize {
pub fn to_t2i(self) -> EnqueueGptImage2TextToImageSize {
use EnqueueGptImage2TextToImageSize as T;
match self {
Self::SquareHd => T::SquareHd,
Self::Square => T::Square,
Self::Portrait4x3 => T::Portrait4x3,
Self::Portrait16x9 => T::Portrait16x9,
Self::Landscape4x3 => T::Landscape4x3,
Self::Landscape16x9 => T::Landscape16x9,
Self::Auto => T::SquareHd,
}
}
pub fn to_edit(self) -> EnqueueGptImage2EditImageSize {
use EnqueueGptImage2EditImageSize as E;
match self {
Self::SquareHd => E::SquareHd,
Self::Square => E::Square,
Self::Portrait4x3 => E::Portrait4x3,
Self::Portrait16x9 => E::Portrait16x9,
Self::Landscape4x3 => E::Landscape4x3,
Self::Landscape16x9 => E::Landscape16x9,
Self::Auto => E::Auto,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
// ── Mode detection ──
mod mode_detection {
use super::*;
#[test]
fn no_image_inputs_yields_empty_urls() {
let plan = build_plan(&GenerateImageRequest { image_inputs: None, ..base_request() });
assert!(plan.image_urls.is_empty());
}
#[test]
fn url_image_inputs_are_extracted() {
let urls = vec![
"https://example.com/a.jpg".to_string(),
"https://example.com/b.jpg".to_string(),
];
let plan = build_plan(&GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(urls.clone())),
..base_request()
});
assert_eq!(plan.image_urls, urls);
}
#[test]
fn media_token_inputs_return_error() {
let result = plan_generate_image_fal_gpt_image_2(&GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))));
}
}
// ── Quality ──
mod quality_tests {
use super::*;
#[test]
fn defaults_to_high() {
let plan = build_plan(&base_request());
assert!(matches!(plan.quality, FalGptImage2Quality::High));
}
#[test]
fn low() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Low), ..base_request() });
assert!(matches!(plan.quality, FalGptImage2Quality::Low));
}
#[test]
fn medium() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Medium), ..base_request() });
assert!(matches!(plan.quality, FalGptImage2Quality::Medium));
}
#[test]
fn high() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::High), ..base_request() });
assert!(matches!(plan.quality, FalGptImage2Quality::High));
}
}
// ── Image size mapping ──
mod image_size_tests {
use super::*;
#[test]
fn none_yields_auto() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: None, ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Auto)));
}
#[test]
fn auto_variants_yield_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Auto)), "expected Auto for {:?}", ar);
}
}
#[test]
fn square() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::Square), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Square)));
}
#[test]
fn square_hd() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::SquareHd), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::SquareHd)));
}
#[test]
fn landscape_4x3_variants() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::WideFiveByFour] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Landscape4x3)), "expected Landscape4x3 for {:?}", ar);
}
}
#[test]
fn landscape_16x9_variants() {
for ar in [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Landscape16x9)), "expected Landscape16x9 for {:?}", ar);
}
}
#[test]
fn portrait_4x3_variants() {
for ar in [CommonAspectRatio::TallThreeByFour, CommonAspectRatio::TallFourByFive] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Portrait4x3)), "expected Portrait4x3 for {:?}", ar);
}
}
#[test]
fn portrait_16x9_variants() {
for ar in [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Portrait16x9)), "expected Portrait16x9 for {:?}", ar);
}
}
}
// ── Num images ──
mod num_images_tests {
use super::*;
#[test]
fn default_is_one() {
let plan = build_plan(&GenerateImageRequest { image_batch_count: None, ..base_request() });
assert!(matches!(plan.num_images, FalGptImage2NumImages::One));
}
#[test]
fn direct_mapping() {
for (count, expected) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let plan = build_plan(&GenerateImageRequest { image_batch_count: Some(count), ..base_request() });
assert_eq!(plan.num_images.as_u64(), expected);
}
}
#[test]
fn zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let result = plan_generate_image_fal_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))));
}
}
#[test]
fn out_of_range_error_out() {
let result = plan_generate_image_fal_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))));
}
#[test]
fn out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let plan = build_plan(&GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(plan.num_images, FalGptImage2NumImages::Four));
}
}
}
// ── as_u64 ──
#[test]
fn num_images_as_u64() {
assert_eq!(FalGptImage2NumImages::One.as_u64(), 1);
assert_eq!(FalGptImage2NumImages::Two.as_u64(), 2);
assert_eq!(FalGptImage2NumImages::Three.as_u64(), 3);
assert_eq!(FalGptImage2NumImages::Four.as_u64(), 4);
}
// ── Conversions to fal-client enqueue enums ──
mod conversion_tests {
use super::*;
#[test]
fn num_images_to_t2i() {
use EnqueueGptImage2TextToImageNumImages as T;
assert!(matches!(FalGptImage2NumImages::One.to_t2i(), T::One));
assert!(matches!(FalGptImage2NumImages::Two.to_t2i(), T::Two));
assert!(matches!(FalGptImage2NumImages::Three.to_t2i(), T::Three));
assert!(matches!(FalGptImage2NumImages::Four.to_t2i(), T::Four));
}
#[test]
fn num_images_to_edit() {
use EnqueueGptImage2EditImageNumImages as E;
assert!(matches!(FalGptImage2NumImages::One.to_edit(), E::One));
assert!(matches!(FalGptImage2NumImages::Two.to_edit(), E::Two));
assert!(matches!(FalGptImage2NumImages::Three.to_edit(), E::Three));
assert!(matches!(FalGptImage2NumImages::Four.to_edit(), E::Four));
}
#[test]
fn quality_to_t2i() {
use EnqueueGptImage2TextToImageQuality as T;
assert!(matches!(FalGptImage2Quality::Low.to_t2i(), T::Low));
assert!(matches!(FalGptImage2Quality::Medium.to_t2i(), T::Medium));
assert!(matches!(FalGptImage2Quality::High.to_t2i(), T::High));
}
#[test]
fn quality_to_edit() {
use EnqueueGptImage2EditImageQuality as E;
assert!(matches!(FalGptImage2Quality::Low.to_edit(), E::Low));
assert!(matches!(FalGptImage2Quality::Medium.to_edit(), E::Medium));
assert!(matches!(FalGptImage2Quality::High.to_edit(), E::High));
}
#[test]
fn size_to_t2i() {
use EnqueueGptImage2TextToImageSize as T;
assert!(matches!(FalGptImage2ImageSize::SquareHd.to_t2i(), T::SquareHd));
assert!(matches!(FalGptImage2ImageSize::Square.to_t2i(), T::Square));
assert!(matches!(FalGptImage2ImageSize::Portrait4x3.to_t2i(), T::Portrait4x3));
assert!(matches!(FalGptImage2ImageSize::Portrait16x9.to_t2i(), T::Portrait16x9));
assert!(matches!(FalGptImage2ImageSize::Landscape4x3.to_t2i(), T::Landscape4x3));
assert!(matches!(FalGptImage2ImageSize::Landscape16x9.to_t2i(), T::Landscape16x9));
// Auto falls back to SquareHd for t2i (which doesn't have an Auto variant)
assert!(matches!(FalGptImage2ImageSize::Auto.to_t2i(), T::SquareHd));
}
#[test]
fn size_to_edit() {
use EnqueueGptImage2EditImageSize as E;
assert!(matches!(FalGptImage2ImageSize::SquareHd.to_edit(), E::SquareHd));
assert!(matches!(FalGptImage2ImageSize::Square.to_edit(), E::Square));
assert!(matches!(FalGptImage2ImageSize::Portrait4x3.to_edit(), E::Portrait4x3));
assert!(matches!(FalGptImage2ImageSize::Portrait16x9.to_edit(), E::Portrait16x9));
assert!(matches!(FalGptImage2ImageSize::Landscape4x3.to_edit(), E::Landscape4x3));
assert!(matches!(FalGptImage2ImageSize::Landscape16x9.to_edit(), E::Landscape16x9));
assert!(matches!(FalGptImage2ImageSize::Auto.to_edit(), E::Auto));
}
}
// ── Helpers ──
fn base_request() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage2,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn build_plan(request: &GenerateImageRequest) -> PlanFalGptImage2 {
let ImageGenerationPlan::FalGptImage2(plan) =
plan_generate_image_fal_gpt_image_2(request).expect("plan should succeed")
else {
panic!("expected FalGptImage2 variant")
};
plan
}
}
@@ -58,10 +58,10 @@ mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16) -> u64 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Pro,
provider: Provider::Artcraft,
prompt: None,
@@ -58,10 +58,10 @@ mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16) -> u64 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Standard,
provider: Provider::Artcraft,
prompt: None,
@@ -61,10 +61,10 @@ mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16) -> u64 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance1p5Pro,
provider: Provider::Artcraft,
prompt: None,
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, GenerateVideoArgs, KinoviModelType, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::api::common_resolution::CommonResolution;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -16,30 +16,29 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
Seedance2p0BatchCount::Four => KinoviBatchCount::Four,
};
// TODO: Make a better client that doesn't require this.
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let output_resolution = plan.resolution.map(map_common_resolution_to_kinovi);
let args = GenerateVideoArgs {
session: &dummy_session,
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution: KinoviResolution::Square1x1,
aspect_ratio: KinoviAspectRatio::Square1x1,
duration_seconds,
batch_count,
output_resolution,
// TODO: This is a cost factor
reference_video_urls: None,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,15 +50,30 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
}
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
// For resolutions that don't map directly, default to 720p
_ => KinoviOutputResolution::SevenTwentyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
@@ -70,7 +84,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -85,23 +99,105 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 250 credits/$1) --
#[test]
fn test_estimate_cost_usd_cents() {
// Batch 1:
assert_eq!(estimate_usd_cents(4, 1), 64);
assert_eq!(estimate_usd_cents(5, 1), 80);
assert_eq!(estimate_usd_cents(6, 1), 96);
assert_eq!(estimate_usd_cents(7, 1), 112);
assert_eq!(estimate_usd_cents(15, 1), 240);
fn pro_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 80);
assert_eq!(estimate_usd_cents(6, 1, Some(CommonResolution::SevenTwentyP)), 96);
assert_eq!(estimate_usd_cents(7, 1, Some(CommonResolution::SevenTwentyP)), 112);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 240);
}
// Batch 2 = 2×
assert_eq!(estimate_usd_cents(4, 2), 128);
assert_eq!(estimate_usd_cents(5, 2), 160);
assert_eq!(estimate_usd_cents(15, 2), 480);
#[test]
fn pro_720p_batch_2() {
assert_eq!(estimate_usd_cents(4, 2, Some(CommonResolution::SevenTwentyP)), 128);
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 2, Some(CommonResolution::SevenTwentyP)), 480);
}
// Batch 4 = 4×
assert_eq!(estimate_usd_cents(4, 4), 256);
assert_eq!(estimate_usd_cents(5, 4), 320);
assert_eq!(estimate_usd_cents(15, 4), 960);
#[test]
fn pro_720p_batch_4() {
assert_eq!(estimate_usd_cents(4, 4, Some(CommonResolution::SevenTwentyP)), 256);
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 320);
assert_eq!(estimate_usd_cents(15, 4, Some(CommonResolution::SevenTwentyP)), 960);
}
#[test]
fn pro_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn pro_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 31);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 39);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 78);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 117);
}
#[test]
fn pro_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn pro_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 155);
}
// -- 1080p (new pricing: 193 credits/$1) --
#[test]
fn pro_1080p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::TenEightyP)), 187);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP)), 233);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::TenEightyP)), 466);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::TenEightyP)), 699);
}
#[test]
fn pro_1080p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)), 466);
}
#[test]
fn pro_1080p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)), 933);
}
// -- Relative pricing --
#[test]
fn pro_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let c1080 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, GenerateVideoArgs, KinoviModelType, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviGenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio};
use crate::api::common_resolution::CommonResolution;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -16,26 +16,29 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
Seedance2p0BatchCount::Four => KinoviBatchCount::Four,
};
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let output_resolution = plan.resolution.map(map_common_resolution_to_kinovi);
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: String::new(),
resolution: KinoviResolution::Square1x1,
aspect_ratio: KinoviAspectRatio::Square1x1,
duration_seconds,
batch_count,
output_resolution,
// TODO: This is a cost factor
reference_video_urls: None,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -47,15 +50,30 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
}
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
// For resolutions that don't map directly, default to 720p
_ => KinoviOutputResolution::SevenTwentyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
@@ -66,7 +84,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -81,8 +99,43 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents_downgrade(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
@@ -93,7 +146,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -108,56 +161,133 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 220 credits/$1) --
#[test]
fn cost_is_present() {
assert!(estimate_usd_cents(5, 1) > 0);
fn fast_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 51);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 127);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 191);
}
#[test]
fn cost_batch_1() {
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
fn fast_720p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 127);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
fn fast_720p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 255);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
fn fast_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn fast_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 21);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 26);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 52);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn fast_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 52);
}
#[test]
fn fast_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 104);
}
// -- 1080p is not supported by Fast; ErrorOut rejects it --
#[test]
fn fast_1080p_error_out_rejects() {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: Some(CommonResolution::TenEightyP),
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
assert!(request.build().is_err());
}
// -- 1080p rounds down to 720p with PayLessDowngrade --
#[test]
fn fast_1080p_downgraded_same_as_720p() {
for duration in [4, 5, 10, 15] {
assert_eq!(
estimate_usd_cents_downgrade(duration, 1, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(duration, 1, Some(CommonResolution::SevenTwentyP)),
);
}
}
// -- Relative pricing --
#[test]
fn fast_480p_cheaper_than_720p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn fast_is_cheaper_than_pro() {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1);
let pro = estimate_pro_usd_cents(duration, 1);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s",
fast, pro, duration,
);
for res in [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
] {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1, res);
let pro = estimate_pro_usd_cents(duration, 1, res);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s {:?}",
fast, pro, duration, res,
);
}
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1);
let c10 = estimate_usd_cents(10, 1);
let c15 = estimate_usd_cents(15, 1);
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1);
let b2 = estimate_usd_cents(5, 2);
let b4 = estimate_usd_cents(5, 4);
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
@@ -1,34 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, KinoviModelType, KinoviResolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
plan: &PlanSeedance2proSeedance2p0,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution: KinoviResolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_credits = plan.request.estimate_credits();
let cost_in_usd_cents = plan.request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -39,3 +16,146 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 250 credits/$1) --
#[test]
fn pro_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 80);
assert_eq!(estimate_usd_cents(6, 1, Some(CommonResolution::SevenTwentyP)), 96);
assert_eq!(estimate_usd_cents(7, 1, Some(CommonResolution::SevenTwentyP)), 112);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 240);
}
#[test]
fn pro_720p_batch_2() {
assert_eq!(estimate_usd_cents(4, 2, Some(CommonResolution::SevenTwentyP)), 128);
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 2, Some(CommonResolution::SevenTwentyP)), 480);
}
#[test]
fn pro_720p_batch_4() {
assert_eq!(estimate_usd_cents(4, 4, Some(CommonResolution::SevenTwentyP)), 256);
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 320);
assert_eq!(estimate_usd_cents(15, 4, Some(CommonResolution::SevenTwentyP)), 960);
}
#[test]
fn pro_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn pro_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 31);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 39);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 78);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 117);
}
#[test]
fn pro_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn pro_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 155);
}
// -- 1080p (new pricing: 193 credits/$1) --
#[test]
fn pro_1080p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::TenEightyP)), 187);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP)), 233);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::TenEightyP)), 466);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::TenEightyP)), 699);
}
#[test]
fn pro_1080p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)), 466);
}
#[test]
fn pro_1080p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)), 933);
}
// -- Relative pricing --
#[test]
fn pro_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let c1080 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -1,34 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, KinoviModelType, KinoviResolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: KinoviResolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_credits = plan.request.estimate_credits();
let cost_in_usd_cents = plan.request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -42,13 +19,18 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
prompt: None,
@@ -59,7 +41,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -74,36 +56,12 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
let cents = estimate_usd_cents(5, 1);
assert!(cents > 0);
}
#[test]
fn cost_batch_1() {
// Seedance2Fast: 28 credits/sec, 220 credits/$1 (with rounding)
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
let fast = estimate_usd_cents(5, 1);
let pro_request = GenerateVideoRequest {
fn estimate_pro_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
@@ -114,16 +72,140 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
let pro = pro_request.build().unwrap().estimate_costs().cost_in_usd_cents.unwrap();
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
assert!(fast < pro, "Fast ({}) should be cheaper than Pro ({})", fast, pro);
// -- 720p (default / legacy pricing: 220 credits/$1) --
#[test]
fn fast_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 51);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 127);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 191);
}
#[test]
fn fast_720p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 127);
}
#[test]
fn fast_720p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 255);
}
#[test]
fn fast_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn fast_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 21);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 26);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 52);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn fast_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 52);
}
#[test]
fn fast_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 104);
}
// -- 1080p maps to 720p for Fast (Kinovi doesn't support 1080p fast) --
#[test]
fn fast_1080p_same_as_720p() {
for duration in [4, 5, 10, 15] {
assert_eq!(
estimate_usd_cents(duration, 1, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(duration, 1, Some(CommonResolution::SevenTwentyP)),
);
}
}
#[test]
fn fast_1080p_batch_2() {
assert_eq!(
estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)),
);
}
#[test]
fn fast_1080p_batch_4() {
assert_eq!(
estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)),
);
}
// -- Relative pricing --
#[test]
fn fast_480p_cheaper_than_720p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn fast_is_cheaper_than_pro() {
for res in [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
] {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1, res);
let pro = estimate_pro_usd_cents(duration, 1, res);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s {:?}",
fast, pro, duration, res,
);
}
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -5,13 +5,23 @@ use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraf
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use crate::api::common_resolution::CommonResolution as CommonResolutionRouter;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
};
use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
pub async fn execute_artcraft_seedance2p0(
plan: &PlanArtcraftSeedance2p0,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let output_resolution = plan.resolution.map(|r| match r {
CommonResolutionRouter::FourEightyP => Seedance2p0OutputResolution::FourEightyP,
CommonResolutionRouter::SevenTwentyP => Seedance2p0OutputResolution::SevenTwentyP,
CommonResolutionRouter::TenEightyP => Seedance2p0OutputResolution::TenEightyP,
_ => Seedance2p0OutputResolution::SevenTwentyP,
});
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.clone(),
@@ -22,6 +32,7 @@ pub async fn execute_artcraft_seedance2p0(
reference_audio_media_tokens: plan.reference_audio.clone(),
reference_character_tokens: plan.reference_characters.clone(),
aspect_ratio: plan.aspect_ratio,
output_resolution,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
};
@@ -50,7 +61,7 @@ mod tests {
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_seedance_2p0() {
let client = get_artcraft_client();
let request = crate::generate::generate_video::generate_video_request::GenerateVideoRequest {
let request = crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
video_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
@@ -73,7 +84,7 @@ mod tests {
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_seedance_2p0_batch_two() {
let client = get_artcraft_client();
let request = crate::generate::generate_video::generate_video_request::GenerateVideoRequest {
let request = crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
video_batch_count: Some(2),
prompt: Some("a dog surfing a wave, cinematic".to_string()),
@@ -1,3 +1,4 @@
use crate::api::common_resolution::CommonResolution as CommonResolutionRouter;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -5,9 +6,11 @@ use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0AspectRatio, Seedance2p0BatchCount};
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
/// Execute Seedance 2.0 Fast via the Artcraft omni-gen video endpoint.
@@ -19,34 +22,45 @@ pub async fn execute_artcraft_seedance2p0_fast(
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let aspect_ratio = plan.aspect_ratio.map(|ar| match ar {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
});
let resolution = plan.resolution.map(|r| match r {
CommonResolutionRouter::FourEightyP => CommonResolution::FourEightyP,
CommonResolutionRouter::SevenTwentyP => CommonResolution::SevenTwentyP,
CommonResolutionRouter::TenEightyP => CommonResolution::TenEightyP,
CommonResolutionRouter::HalfK => CommonResolution::HalfK,
CommonResolutionRouter::OneK => CommonResolution::OneK,
CommonResolutionRouter::TwoK => CommonResolution::TwoK,
CommonResolutionRouter::ThreeK => CommonResolution::ThreeK,
CommonResolutionRouter::FourK => CommonResolution::FourK,
});
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Seedance2p0Fast),
prompt: plan.prompt.clone(),
negative_prompt: None,
start_frame_image_media_token: plan.start_frame.clone(),
end_frame_image_media_token: plan.end_frame.clone(),
reference_image_media_tokens: plan.reference_images.clone(),
reference_video_media_tokens: plan.reference_videos.clone(),
reference_audio_media_tokens: plan.reference_audio.clone(),
reference_character_tokens: None,
resolution: None,
reference_character_tokens: plan.reference_characters.clone(),
resolution,
aspect_ratio,
quality: None,
duration_seconds: plan.duration_seconds.map(|d| d as u16),
video_batch_count: Some(match plan.batch_count {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::One => 1,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Two => 2,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Four => 4,
Seedance2p0BatchCount::One => 1,
Seedance2p0BatchCount::Two => 2,
Seedance2p0BatchCount::Four => 4,
}),
quality: None,
generate_audio: None,
negative_prompt: None,
};
let response = omni_gen_video_generate(
@@ -63,3 +77,160 @@ pub async fn execute_artcraft_seedance2p0_fast(
all_inference_job_tokens: all_tokens,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::test_helpers::{base_video_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
fn fast_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
prompt: Some("a corgi walking through a cyberpunk city at night".to_string()),
..base_video_request()
}
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_720p_wide() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_480p_square() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::FourEightyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_fast_with_start_keyframe() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(
MediaFileToken::new("mf_test_keyframe_placeholder".to_string()),
)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_batch_2() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(2),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job tokens ({} total):", payload.all_inference_job_tokens.len());
for token in &payload.all_inference_job_tokens {
println!(" {:?}", token);
}
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_with_reference_images() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
reference_images: Some(crate::api::image_list_ref::ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test_ref_image_placeholder".to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[test]
fn plan_includes_resolution() {
let request = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
..fast_request()
};
let plan = request.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.resolution, Some(CommonResolution::FourEightyP)));
}
#[test]
fn plan_includes_characters() {
use crate::api::character_list_ref::CharacterListRef;
use tokens::tokens::characters::CharacterToken;
let request = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_test123".to_string()),
])),
..fast_request()
};
let plan = request.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(p.reference_characters.is_some());
assert_eq!(p.reference_characters.unwrap().len(), 1);
}
}
@@ -7,7 +7,7 @@ use crate::generate::generate_video::generate_video_response::{
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::utils::download_file::download_file;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviModelType,
generate_video, GenerateVideoArgs,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
@@ -26,27 +26,23 @@ pub async fn execute_seedance2pro_seedance2p0(
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let start_frame_url = upload_optional_url(session, plan.request.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.request.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.request.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.request.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.request.reference_audio_urls.as_deref()).await?;
let mut request = plan.request.clone();
request.start_frame_url = start_frame_url;
request.end_frame_url = end_frame_url;
request.reference_image_urls = reference_image_urls;
request.reference_video_urls = reference_video_urls;
request.reference_audio_urls = reference_audio_urls;
let args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Pro,
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
request,
};
let response = generate_video(args)
@@ -56,6 +52,8 @@ pub async fn execute_seedance2pro_seedance2p0(
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
@@ -1,15 +1,15 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, Seedance2proVideoResponsePayload,
};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviModelType,
generate_video, GenerateVideoArgs,
};
/// Execute Seedance 2.0 Fast via the Seedance2Pro/Kinovi provider.
@@ -23,27 +23,23 @@ pub async fn execute_seedance2pro_seedance2p0_fast(
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let start_frame_url = upload_optional_url(session, plan.request.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.request.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.request.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.request.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.request.reference_audio_urls.as_deref()).await?;
let mut request = plan.request.clone();
request.start_frame_url = start_frame_url;
request.end_frame_url = end_frame_url;
request.reference_image_urls = reference_image_urls;
request.reference_video_urls = reference_video_urls;
request.reference_audio_urls = reference_audio_urls;
let args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
request,
};
let response = generate_video(args)
@@ -53,5 +49,7 @@ pub async fn execute_seedance2pro_seedance2p0_fast(
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
@@ -9,17 +9,17 @@ use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::plan_generate_video_artcraft_kling3p0_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_standard::plan_generate_video_artcraft_kling3p0_standard;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_1_6_pro::plan_generate_video_artcraft_kling_1_6_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_1_master::plan_generate_video_artcraft_kling_2_1_master;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_1_pro::plan_generate_video_artcraft_kling_2_1_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_5_turbo_pro::plan_generate_video_artcraft_kling_2_5_turbo_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_6_pro::plan_generate_video_artcraft_kling_2_6_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::plan_generate_video_artcraft_kling3p0_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_standard::plan_generate_video_artcraft_kling3p0_standard;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::plan_generate_video_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance1p5_pro::plan_generate_video_artcraft_seedance1p5_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0_fast::plan_generate_video_artcraft_seedance2p0_fast;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::plan_generate_video_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2::plan_generate_video_artcraft_sora_2;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2_pro::plan_generate_video_artcraft_sora_2_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::plan_generate_video_artcraft_veo_2;
@@ -47,12 +47,19 @@ use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seed
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::build::build_artcraft_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
/// Plan to either (1) generate a video or (2) determine how much it costs to generate that video.
/// This works across multiple providers by shaping a generic "GenerateVideoRequest" into a provider-specific plan.
/// That plan can then be used to return a cost estimate for that given provider or return a struct that can be used to send a real generation request.
#[derive(Clone, Debug)]
pub struct GenerateVideoRequest {
pub struct GenerateVideoRequestBuilder {
/// Which model to use.
pub model: CommonVideoModel,
@@ -109,7 +116,57 @@ pub struct GenerateVideoRequest {
pub idempotency_token: Option<String>,
}
impl GenerateVideoRequest {
impl Default for GenerateVideoRequestBuilder {
fn default() -> Self {
Self {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
idempotency_token: None,
}
}
}
impl GenerateVideoRequestBuilder {
pub fn use_new_builder(&self) -> bool {
match (self.provider, self.model) {
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true,
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true,
_ => false,
}
}
// New builder (transitional state)
pub fn build2(self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
_ => self.unsupported_provider_and_model(),
}
}
/// Read the video generation request, construct a plan, then yield a means to execute it.
pub fn build(&self) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
match self.provider {
@@ -188,6 +245,12 @@ impl GenerateVideoRequest {
))
}
fn unsupported_provider_and_model(&self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(
format!("Video generation for model `{:?}` is not supported for provider {:?} using the new API", self.model, self.provider)
))
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
@@ -16,6 +16,8 @@ pub struct MuapiVideoResponsePayload {
pub struct Seedance2proVideoResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
pub maybe_task_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug)]
@@ -1,6 +1,6 @@
pub mod cost;
pub mod execute;
pub mod generate_video_request;
pub mod generate_video_request_builder;
pub mod generate_video_response;
pub mod plan;
pub mod video_generation_cost_estimate;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_3p0_pro_multi_function_video_gen::{
Kling3p0ProMultiFunctionVideoGenAspectRatio,
@@ -24,7 +24,7 @@ pub struct PlanArtcraftKling3p0Pro {
}
pub fn plan_generate_video_artcraft_kling3p0_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -166,11 +166,11 @@ mod tests {
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
fn base_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Pro,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -193,7 +193,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
@@ -203,7 +203,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
@@ -213,7 +213,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
@@ -223,7 +223,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -238,7 +238,7 @@ mod tests {
#[test]
fn duration_valid_range() {
for d in 3..=15 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(d),
..base_request()
};
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn duration_out_of_range_error_out() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(16),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -263,7 +263,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_request()
};
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_3p0_standard_multi_function_video_gen::{
Kling3p0StandardMultiFunctionVideoGenAspectRatio,
@@ -24,7 +24,7 @@ pub struct PlanArtcraftKling3p0Standard {
}
pub fn plan_generate_video_artcraft_kling3p0_standard(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -166,11 +166,11 @@ mod tests {
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
fn base_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Standard,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -193,7 +193,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
@@ -203,7 +203,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
@@ -213,7 +213,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
@@ -223,7 +223,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -238,7 +238,7 @@ mod tests {
#[test]
fn duration_valid_range() {
for d in 3..=15 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(d),
..base_request()
};
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn duration_out_of_range_error_out() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(16),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -263,7 +263,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_request()
};
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{
GenerateKling16ProAspectRatio, GenerateKling16ProDuration,
@@ -21,7 +21,7 @@ pub struct PlanArtcraftKling16Pro {
}
pub fn plan_generate_video_artcraft_kling_1_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{
GenerateKling21MasterAspectRatio, GenerateKling21MasterDuration,
@@ -20,7 +20,7 @@ pub struct PlanArtcraftKling21Master {
}
pub fn plan_generate_video_artcraft_kling_2_1_master(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{
GenerateKling21ProAspectRatio, GenerateKling21ProDuration,
@@ -21,7 +21,7 @@ pub struct PlanArtcraftKling21Pro {
}
pub fn plan_generate_video_artcraft_kling_2_1_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_2_5_turbo_multi_function_video_gen::{
Kling2p5TurboProMultiFunctionVideoGenAspectRatio, Kling2p5TurboProMultiFunctionVideoGenDuration,
@@ -22,7 +22,7 @@ pub struct PlanArtcraftKling2p5TurboPro {
}
pub fn plan_generate_video_artcraft_kling_2_5_turbo_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_2_6_multi_function_video_gen::{
Kling2p6ProMultiFunctionVideoGenAspectRatio, Kling2p6ProMultiFunctionVideoGenDuration,
@@ -22,7 +22,7 @@ pub struct PlanArtcraftKling2p6Pro {
}
pub fn plan_generate_video_artcraft_kling_2_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_1p5_pro_multi_function_video_gen::{
Seedance1p5ProMultiFunctionVideoGenAspectRatio,
@@ -25,7 +25,7 @@ pub struct PlanArtcraftSeedance1p5Pro {
}
pub fn plan_generate_video_artcraft_seedance1p5_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -184,11 +184,11 @@ mod tests {
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
fn base_seedance_1p5_pro_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_seedance_1p5_pro_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance1p5Pro,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -211,7 +211,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_seedance_1p5_pro_request()
};
@@ -221,7 +221,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_seedance_1p5_pro_request()
};
@@ -231,7 +231,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_21x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideTwentyOneByNine),
..base_seedance_1p5_pro_request()
};
@@ -241,7 +241,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedance_1p5_pro_request()
@@ -256,7 +256,7 @@ mod tests {
#[test]
fn duration_valid_range() {
for d in 4..=12 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(d),
..base_seedance_1p5_pro_request()
};
@@ -267,7 +267,7 @@ mod tests {
#[test]
fn duration_out_of_range_error_out() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(13),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedance_1p5_pro_request()
@@ -281,7 +281,7 @@ mod tests {
#[test]
fn url_image_ref_accepted_for_cost_path() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_seedance_1p5_pro_request()
};
@@ -1,12 +1,13 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
@@ -24,13 +25,14 @@ pub struct PlanArtcraftSeedance2p0 {
pub reference_audio: Option<Vec<MediaFileToken>>,
pub reference_characters: Option<Vec<CharacterToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub resolution: Option<CommonResolution>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
pub idempotency_token: String,
}
pub fn plan_generate_video_artcraft_seedance2p0(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -41,6 +43,7 @@ pub fn plan_generate_video_artcraft_seedance2p0(
let reference_audio = resolve_audio_list_ref(request.reference_audio.clone())?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_output_resolution(request.resolution, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
@@ -53,6 +56,7 @@ pub fn plan_generate_video_artcraft_seedance2p0(
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens.clone()),
aspect_ratio,
resolution,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
@@ -228,6 +232,45 @@ fn plan_duration(
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP)
| Some(CommonResolution::SevenTwentyP)
| Some(CommonResolution::TenEightyP) => Ok(resolution),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// HalfK → 480p (up); OneK/TwoK/ThreeK/FourK → 1080p (max)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// HalfK → 480p (min); OneK/TwoK/ThreeK/FourK → 1080p (closest below)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::TenEightyP,
}))
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,7 +286,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_video_request()
};
@@ -253,7 +296,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_video_request()
};
@@ -263,7 +306,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_video_request()
};
@@ -278,7 +321,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: strategy,
..base_video_request()
@@ -293,7 +336,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_video_request()
@@ -312,7 +355,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
video_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_video_request()
@@ -327,22 +370,22 @@ mod tests {
#[test]
fn batch_count_direct_mapping() {
let req = GenerateVideoRequest { video_batch_count: Some(1), ..base_video_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..base_video_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = req.build().unwrap() else { panic!("wrong variant") };
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::One));
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_video_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..base_video_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = req.build().unwrap() else { panic!("wrong variant") };
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_video_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..base_video_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = req.build().unwrap() else { panic!("wrong variant") };
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn batch_count_three_upgrade_rounds_to_four() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_video_request()
@@ -353,7 +396,7 @@ mod tests {
#[test]
fn batch_count_three_downgrade_rounds_to_two() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_video_request()
@@ -364,7 +407,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_video_request()
};
@@ -1,12 +1,13 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
@@ -16,7 +17,7 @@ use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
pub fn plan_generate_video_artcraft_seedance2p0_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -27,6 +28,7 @@ pub fn plan_generate_video_artcraft_seedance2p0_fast(
let reference_audio = resolve_audio_list_ref(request.reference_audio.clone())?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_output_resolution(request.resolution, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
@@ -39,6 +41,7 @@ pub fn plan_generate_video_artcraft_seedance2p0_fast(
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens.clone()),
aspect_ratio,
resolution,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
@@ -199,6 +202,44 @@ fn plan_duration(
}
}
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP)
| Some(CommonResolution::SevenTwentyP) => Ok(resolution),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// HalfK → 480p (up); TenEightyP/OneK+ → 720p (max)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::SevenTwentyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// HalfK → 480p (min); TenEightyP/OneK+ → 720p (closest below)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::SevenTwentyP,
}))
}
},
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
@@ -208,14 +249,14 @@ mod tests {
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
fn base_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -254,7 +295,7 @@ mod tests {
#[test]
fn aspect_ratio_16x9() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
@@ -265,7 +306,7 @@ mod tests {
#[test]
fn aspect_ratio_9x16() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
@@ -276,7 +317,7 @@ mod tests {
#[test]
fn aspect_ratio_square() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
@@ -295,7 +336,7 @@ mod tests {
#[test]
fn batch_count_2() {
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Two));
@@ -303,7 +344,7 @@ mod tests {
#[test]
fn batch_count_4() {
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Four));
@@ -311,7 +352,7 @@ mod tests {
#[test]
fn duration_in_range() {
let req = GenerateVideoRequest { duration_seconds: Some(10), ..base_request() };
let req = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(10));
@@ -319,7 +360,7 @@ mod tests {
#[test]
fn duration_clamped_to_max() {
let req = GenerateVideoRequest { duration_seconds: Some(99), ..base_request() };
let req = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(15));
@@ -327,7 +368,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_request()
};
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::{
GenerateSeedance10LiteAspectRatio, GenerateSeedance10LiteDuration,
@@ -24,7 +24,7 @@ pub struct PlanArtcraftSeedance10Lite {
}
pub fn plan_generate_video_artcraft_seedance_1_0_lite(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::sora_2_multi_function_video_gen::{
Sora2MultiFunctionVideoGenAspectRatio, Sora2MultiFunctionVideoGenDuration,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftSora2 {
}
pub fn plan_generate_video_artcraft_sora_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::sora_2_pro_multi_function_video_gen::{
Sora2ProMultiFunctionVideoGenAspectRatio, Sora2ProMultiFunctionVideoGenDuration,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftSora2Pro {
}
pub fn plan_generate_video_artcraft_sora_2_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{
GenerateVeo2AspectRatio, GenerateVeo2Duration,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftVeo2 {
}
pub fn plan_generate_video_artcraft_veo_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{
GenerateVeo3AspectRatio, GenerateVeo3Duration, GenerateVeo3Resolution,
@@ -29,7 +29,7 @@ pub struct PlanArtcraftVeo3 {
}
pub fn plan_generate_video_artcraft_veo_3(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{
GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastResolution,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftVeo3Fast {
}
pub fn plan_generate_video_artcraft_veo_3_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::veo_3p1_multi_function_video_gen::{
Veo3p1MultiFunctionVideoGenAspectRatio, Veo3p1MultiFunctionVideoGenDuration,
@@ -26,14 +26,14 @@ pub struct PlanArtcraftVeo3p1 {
}
pub fn plan_generate_video_artcraft_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let plan = build_plan_artcraft_veo_3p1(request)?;
Ok(VideoGenerationPlan::ArtcraftVeo3p1(plan))
}
pub(crate) fn build_plan_artcraft_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<PlanArtcraftVeo3p1, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::veo_3p1_fast_multi_function_video_gen::{
Veo3p1FastMultiFunctionVideoGenAspectRatio, Veo3p1FastMultiFunctionVideoGenDuration,
@@ -26,7 +26,7 @@ pub struct PlanArtcraftVeo3p1Fast {
}
pub fn plan_generate_video_artcraft_veo_3p1_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
Kling1p6ProAspectRatio, Kling1p6ProDuration,
@@ -19,7 +19,7 @@ pub struct PlanFalKling16Pro {
}
pub fn plan_generate_video_fal_kling_1_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::require_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::{
@@ -18,7 +18,7 @@ pub struct PlanFalKling21Master {
}
pub fn plan_generate_video_fal_kling_2_1_master(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::{optional_url, require_url};
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::{
@@ -19,7 +19,7 @@ pub struct PlanFalKling21Pro {
}
pub fn plan_generate_video_fal_kling_2_1_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -38,7 +38,7 @@ pub struct PlanFalKling2p5TurboPro {
}
pub fn plan_generate_video_fal_kling_2_5_turbo_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -36,7 +36,7 @@ pub struct PlanFalKling2p6Pro {
}
pub fn plan_generate_video_fal_kling_2_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -37,7 +37,7 @@ pub struct PlanFalKling3p0Pro {
}
pub fn plan_generate_video_fal_kling_3p0_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let inner = build_kling_3p0_plan(request, "Kling 3.0 Pro")?;
Ok(VideoGenerationPlan::FalKling3p0Pro(PlanFalKling3p0Pro {
@@ -60,7 +60,7 @@ pub(crate) struct Kling3p0Common {
}
pub(crate) fn build_kling_3p0_plan(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
_model_label: &'static str,
) -> Result<Kling3p0Common, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -1,5 +1,5 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_3p0_pro::{
build_kling_3p0_plan, FalKling3p0AspectRatio, FalKling3p0Duration, FalKling3p0Mode,
};
@@ -16,7 +16,7 @@ pub struct PlanFalKling3p0Standard {
}
pub fn plan_generate_video_fal_kling_3p0_standard(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let inner = build_kling_3p0_plan(request, "Kling 3.0 Standard")?;
Ok(VideoGenerationPlan::FalKling3p0Standard(PlanFalKling3p0Standard {
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::{optional_url, require_url};
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{
@@ -21,7 +21,7 @@ pub struct PlanFalSeedance10Lite {
}
pub fn plan_generate_video_fal_seedance_1_0_lite(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -58,7 +58,7 @@ pub struct PlanFalSeedance1p5Pro {
}
pub fn plan_generate_video_fal_seedance_1p5_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -44,7 +44,7 @@ pub struct PlanFalSora2 {
}
pub fn plan_generate_video_fal_sora_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -44,7 +44,7 @@ pub struct PlanFalSora2Pro {
}
pub fn plan_generate_video_fal_sora_2_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
@@ -29,7 +29,7 @@ pub struct PlanFalVeo2 {
}
pub fn plan_generate_video_fal_veo_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution,
@@ -70,7 +70,7 @@ pub struct PlanFalVeo3 {
}
pub fn plan_generate_video_fal_veo_3(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
@@ -21,7 +21,7 @@ pub struct PlanFalVeo3Fast {
}
pub fn plan_generate_video_fal_veo_3_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Three-way internal selection of which Fal Veo 3.1 endpoint to call.
@@ -49,14 +49,14 @@ pub struct PlanFalVeo3p1 {
}
pub fn plan_generate_video_fal_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let plan = build_plan_fal_veo_3p1(request, "Veo 3.1")?;
Ok(VideoGenerationPlan::FalVeo3p1(plan))
}
pub(crate) fn build_plan_fal_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
model_label: &'static str,
) -> Result<PlanFalVeo3p1, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -1,5 +1,5 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1::{
build_plan_fal_veo_3p1, PlanFalVeo3p1,
};
@@ -14,7 +14,7 @@ pub struct PlanFalVeo3p1Fast {
}
pub fn plan_generate_video_fal_veo_3p1_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let inner = build_plan_fal_veo_3p1(request, "Veo 3.1 Fast")?;
Ok(VideoGenerationPlan::FalVeo3p1Fast(PlanFalVeo3p1Fast { inner }))
@@ -3,7 +3,7 @@ use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use muapi_client::requests::seedance_2p0_image_to_video::seedance_2p0_image_to_video::{
AspectRatio as I2vAspectRatio,
@@ -41,7 +41,7 @@ pub struct PlanMuapiSeedance2p0 {
}
pub fn plan_generate_video_muapi_seedance2p0(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -6,25 +6,22 @@ use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{
KinoviGenerateVideoRequest as SeedanceGenerateVideoRequest,
KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use crate::api::common_resolution::CommonResolution;
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0 {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: KinoviResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub request: SeedanceGenerateVideoRequest,
}
pub fn plan_generate_video_seedance2pro_seedance2p0(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -34,23 +31,40 @@ pub fn plan_generate_video_seedance2pro_seedance2p0(
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio.clone())?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
let output_resolution = request.resolution.map(map_common_resolution_to_kinovi);
Ok(VideoGenerationPlan::Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0 {
prompt: request.prompt.clone(),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
request: SeedanceGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: request.prompt.clone().unwrap_or_default(),
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
}))
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -104,29 +118,29 @@ fn resolve_audio_list_ref_urls(
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_resolution(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviResolution, ArtcraftRouterError> {
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviResolution::Landscape16x9),
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviResolution::Landscape16x9)
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviResolution::Portrait9x16)
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviResolution::Square1x1)
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviResolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviResolution::Portrait3x4),
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -145,15 +159,15 @@ fn plan_resolution(
}
/// Pick the nearest supported resolution by AR value (width / height).
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviResolution {
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviResolution::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviResolution::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviResolution::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviResolution::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviResolution::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviResolution::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviResolution::Square1x1,
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
@@ -6,25 +6,22 @@ use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{
KinoviGenerateVideoRequest as SeedanceGenerateVideoRequest,
KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use crate::api::common_resolution::CommonResolution;
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0Fast {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: KinoviResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub request: SeedanceGenerateVideoRequest,
}
pub fn plan_generate_video_seedance2pro_seedance2p0_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -34,23 +31,40 @@ pub fn plan_generate_video_seedance2pro_seedance2p0_fast(
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio.clone())?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
let output_resolution = request.resolution.map(map_common_resolution_to_kinovi);
Ok(VideoGenerationPlan::Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast {
prompt: request.prompt.clone(),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
request: SeedanceGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: request.prompt.clone().unwrap_or_default(),
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
}))
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::SevenTwentyP, // NB: Kinovi doesn't have 1080p fast currently
_ => KinoviOutputResolution::SevenTwentyP,
}
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -99,27 +113,27 @@ fn resolve_audio_list_ref_urls(
}
}
fn plan_resolution(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviResolution, ArtcraftRouterError> {
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviResolution::Landscape16x9),
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviResolution::Landscape16x9)
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviResolution::Portrait9x16)
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviResolution::Square1x1)
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviResolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviResolution::Portrait3x4),
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -136,15 +150,15 @@ fn plan_resolution(
}
}
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviResolution {
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviResolution::Standard4x3,
CommonAspectRatio::WideThreeByTwo => KinoviResolution::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviResolution::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviResolution::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviResolution::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviResolution::Portrait9x16,
_ => KinoviResolution::Square1x1,
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16,
_ => KinoviAspectRatio::Square1x1,
}
}
@@ -1,5 +1,5 @@
/// The result of estimating the cost of a video generation plan.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct VideoGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
@@ -0,0 +1,127 @@
# generate_video_v2
This module implements the v2 video generation pipeline. It replaces the v1 system
(in `generate_video/`) with a two-phase approach that separates request planning from
execution, allowing cost estimation and media uploads to happen at the right time.
## Lifecycle
```
GenerateVideoRequestBuilder
│
▼ build2()
VideoGenerationDraftOrRequest
│
├─ Draft(VideoGenerationDraftRequest)
│ │
│ ▼ estimate_cost() ← cost estimate from draft state
│ │
│ ▼ finalize(context) ← uploads media, resolves tokens
│ │
│ ▼
│ VideoGenerationRequest
│ │
│ ▼ estimate_cost() ← cost estimate from finalized request
│ │
│ ▼ send_request() ← fires the API call
│ │
│ ▼
│ GenerateVideoResponse
│
└─ Request(VideoGenerationRequest) ← skips draft phase (used by Artcraft provider)
```
### Key types
- **`GenerateVideoRequestBuilder`** — generic builder with provider/model/prompt/resolution/etc.
Located in `generate_video/generate_video_request_builder.rs`. The `use_new_builder()` method
gates which provider+model combos use the v2 path. `build2()` dispatches to the correct
provider's build function.
- **`VideoGenerationDraftOrRequest`** — the output of `build2()`. Either `Draft` (for providers
that need media upload/resolution, like Kinovi) or `Request` (for providers that work with
media file tokens directly, like Artcraft).
- **`VideoGenerationDraftRequest`** — enum with one variant per provider+model. Holds
materialized settings (resolution, duration, batch count) plus unresolved media references.
Can estimate cost without network calls.
- **`VideoGenerationDraftContext`** — context needed during `finalize()`: the provider client,
media file token-to-URL map, and character token-to-ID map.
- **`VideoGenerationRequest`** — enum with one variant per provider+model. Holds a fully
materialized request ready to send. All media URLs are resolved and uploaded.
### Cost estimation
Cost can be estimated at two stages:
1. **From draft** — before `finalize()`. Uses the planned resolution/duration/batch count.
Good for showing the user a price before committing.
2. **From request** — after `finalize()`. Uses the final materialized request. Should match
the draft estimate for the same parameters.
## Module layout
```
generate_video_v2/
├── mod.rs
├── video_generation_draft.rs ← VideoGenerationDraftRequest enum
├── video_generation_draft_context.rs ← context for finalize()
├── video_generation_draft_or_request.rs
├── video_generation_request.rs ← VideoGenerationRequest enum
└── providers/
├── artcraft/ ← Artcraft provider (uses media file tokens directly)
│ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
│ │ ├── mod.rs
│ │ ├── build.rs ← builder → Request conversion + plan helpers
│ │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
│ │ └── request.rs ← request state + send() via Artcraft multi-function API
│ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only)
│ ├── mod.rs
│ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p)
│ ├── cost.rs ← standalone cost estimation with Fast-specific pricing
│ └── request.rs ← request state + send() via Artcraft omni-gen API
└── kinovi/ ← Kinovi/Seedance2Pro provider
├── mod.rs
├── resolve.rs ← shared: media token resolution, upload helpers
├── upload.rs ← shared: download + re-upload to Seedance2Pro CDN
├── seedance_2p0/ ← Seedance 2.0 Pro model
│ ├── mod.rs
│ ├── build.rs ← builder → draft conversion + plan helpers
│ ├── cost.rs ← cost estimation state
│ ├── draft.rs ← draft state + finalize (to_request)
│ └── request.rs ← request state + send()
└── seedance_2p0_fast/ ← Seedance 2.0 Fast model
├── mod.rs
├── build.rs
├── cost.rs
├── draft.rs
└── request.rs
```
## Adding a new provider or model
1. **Create the module** under `providers/{provider}/{model}/` with these files:
- `mod.rs` — declares sub-modules
- `build.rs` — `build_{provider}_{model}(builder) -> VideoGenerationDraftOrRequest`.
Contains `plan_*` helpers for aspect ratio, resolution, batch count, duration.
- `draft.rs` — draft state struct with `to_request()` async method for finalization.
**Not needed** if the provider uses media tokens directly (no upload/resolution needed);
in that case `build.rs` returns `VideoGenerationDraftOrRequest::Request(...)` directly.
- `cost.rs` — cost state with `from_request()` and `estimate_cost()`. Add `from_draft()`
if the provider uses the draft phase.
- `request.rs` — request state with `send()` async method.
2. **Register the module** in the parent provider's `mod.rs`.
3. **Add enum variants** in:
- `video_generation_draft.rs` — `VideoGenerationDraftRequest` enum + `estimate_cost` + `finalize`
(skip if provider has no draft phase)
- `video_generation_request.rs` — `VideoGenerationRequest` enum + `estimate_cost` + `send_request`
4. **Wire into the builder** in `generate_video_request_builder.rs`:
- Add the `(Provider, Model)` pair to `use_new_builder()` returning `true`
- Add the dispatch arm in `build2()`
5. **Tests**: each `build.rs` and `cost.rs` should have comprehensive tests. `request.rs`
should have `#[ignore]` live API tests for manual verification.
@@ -0,0 +1,5 @@
pub mod providers;
pub mod video_generation_request;
pub mod video_generation_draft;
pub mod video_generation_draft_or_request;
pub mod video_generation_draft_context;
@@ -0,0 +1,330 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
resolve_image_ref, resolve_video_list_ref,
};
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_happy_horse_1p0(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take();
let start_frame = resolve_image_ref(builder.start_frame.take())?;
let end_frame = resolve_image_ref(builder.end_frame.take())?;
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModelEnum::HappyHorse1p0),
idempotency_token: Some(idempotency_token),
prompt,
start_frame_image_media_token: start_frame,
end_frame_image_media_token: end_frame,
reference_image_media_tokens: reference_images,
reference_video_media_tokens: reference_videos,
reference_audio_media_tokens: reference_audio,
reference_character_tokens: reference_characters,
resolution,
aspect_ratio,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
generate_audio: None,
quality: None,
};
let state = ArtcraftHappyHorse1p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftHappyHorse1p0(state)))
}
// ── Plan helpers ──
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(CommonAspectRatioEnum::Square))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
_ => CommonAspectRatioEnum::Square,
}
}
// Happy Horse supports 720p and 1080p only (no 480p).
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(CommonResolutionEnum::TenEightyP))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(CommonResolutionEnum::SevenTwentyP))
}
},
}
}
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 | 2 | 4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { 2 } else { 4 })
}
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_happy_horse_1p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::HappyHorse1p0)));
}
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
#[test]
fn unsupported_480p_upgrades() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
}
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_happy_horse_1p0(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: crate::api::common_video_model::CommonVideoModel::HappyHorse1p0,
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftHappyHorse1p0RequestState {
let result = build_artcraft_happy_horse_1p0(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftHappyHorse1p0(state)
) => state,
_ => panic!("expected ArtcraftHappyHorse1p0 request"),
}
}
}
@@ -0,0 +1,277 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
// ── Pricing ──
//
// Happy Horse 1.0 upstream credit pricing:
//
// | Resolution | Credits/sec |
// |------------|-------------|
// | 720p | 33 |
// | 1080p | 66 |
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
//
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
pub struct ArtcraftHappyHorse1p0CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftHappyHorse1p0CostState {
pub fn from_request(request: &ArtcraftHappyHorse1p0RequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let credits_per_second = match self.resolution {
CommonResolution::TenEightyP => UPSTREAM_CREDITS_PER_SECOND_1080P,
_ => UPSTREAM_CREDITS_PER_SECOND_720P,
};
let upstream_credits = credits_per_second as u64
* self.duration_seconds as u64
* self.batch_count as u64;
let usd_cents = (upstream_credits as f64 / UPSTREAM_CREDITS_PER_DOLLAR * 100.0).round() as u64;
// ArtCraft credits = USD cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// ── 720p pricing (33 upstream credits/sec) ──
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
// 33 credits/sec; upstream_credits / 193 * 100
// (NB: duration 3s is clamped to 4s by the builder)
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 68);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 85);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 171);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 256);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 171);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 342);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
}
}
// ── 1080p pricing (66 upstream credits/sec) ──
mod pricing_1080p {
use super::*;
#[test]
fn batch_1() {
// 66 credits/sec; upstream_credits / 193 * 100
// (NB: duration 3s is clamped to 4s by the builder)
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 137);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 171);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 342);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 513);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 342);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 684);
}
}
// ── Relative pricing ──
mod relative_pricing {
use super::*;
#[test]
fn cost_720p_cheaper_than_1080p() {
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
assert!(c720 < c1080);
}
#[test]
fn r1080p_approximately_double_720p() {
// 1080p upstream credits are exactly 2× 720p, but USD cents may differ
// by ±1 due to rounding (each is rounded independently).
for dur in [4u16, 5, 10, 15] {
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), dur, 1);
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), dur, 1);
let expected = c720 * 2;
assert!(
c1080 >= expected - 1 && c1080 <= expected + 1,
"1080p ({}) should be ~2× 720p ({}) at {}s", c1080, c720, dur,
);
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = cost_cents(None, 4, 1);
let c10 = cost_cents(None, 10, 1);
let c15 = cost_cents(None, 15, 1);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = cost_cents(None, 5, 1);
let b2 = cost_cents(None, 5, 2);
let b4 = cost_cents(None, 5, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// ── Credits equal cents ──
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
let resolutions = [
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
None,
];
for res in resolutions {
for dur in [4, 5, 10, 15] {
for batch in [1, 2, 4] {
let cost = build_cost(res, dur, batch);
assert_eq!(
cost.cost_in_credits, cost.cost_in_usd_cents,
"credits should equal cents for res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// ── Cross-check with Kinovi ──
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::SevenTwentyP),
None,
Some(CommonResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: CommonVideoModel::HappyHorse1p0,
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
assert_eq!(
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// ── Helpers ──
fn build_cost(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::HappyHorse1p0,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cost_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count)
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,192 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub struct ArtcraftHappyHorse1p0RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftHappyHorse1p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = omni_gen_video_generate(
&client.api_host,
Some(&client.credentials),
self.request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog watches the lake as the sun sets.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::HappyHorse1p0,
provider: Provider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,4 @@
pub mod happy_horse_1p0;
pub(crate) mod resolve;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
@@ -0,0 +1,71 @@
//! Shared resolve helpers for the Artcraft provider.
//!
//! Artcraft only accepts media file tokens — raw URLs are rejected.
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
pub(super) fn resolve_image_ref(
image_ref: Option<ImageRef>,
) -> Result<Option<MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
pub(super) fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
pub(super) fn resolve_video_list_ref(
video_list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
pub(super) fn resolve_audio_list_ref(
audio_list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
pub(super) fn resolve_character_list_ref(
character_list_ref: Option<CharacterListRef>,
) -> Option<Vec<CharacterToken>> {
match character_list_ref {
None => None,
Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
@@ -0,0 +1,344 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
resolve_image_ref, resolve_video_list_ref,
};
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take();
let start_frame = resolve_image_ref(builder.start_frame.take())?;
let end_frame = resolve_image_ref(builder.end_frame.take())?;
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModelEnum::Seedance2p0),
idempotency_token: Some(idempotency_token),
prompt,
start_frame_image_media_token: start_frame,
end_frame_image_media_token: end_frame,
reference_image_media_tokens: reference_images,
reference_video_media_tokens: reference_videos,
reference_audio_media_tokens: reference_audio,
reference_character_tokens: reference_characters,
resolution,
aspect_ratio,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
generate_audio: None,
quality: None,
};
let state = ArtcraftSeedance2p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)))
}
// -- Plan helpers --
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(CommonAspectRatioEnum::Square))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
_ => CommonAspectRatioEnum::Square,
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
},
}
}
// Batch counts: 1, 2, 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 | 2 | 4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { 2 } else { 4 })
}
},
}
}
// Duration: 4-15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
// ── Field conversions ──
mod field_conversions {
use super::*;
#[test]
fn model_is_seedance_2p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0)));
}
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Media tokens ──
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0RequestState {
let result = build_artcraft_seedance_2p0(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -0,0 +1,290 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
// ── Pricing constants ──
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We compute cents directly
// from the upstream credit rates and their credit-package prices,
// then set ArtCraft credits = cents.
/// USD cents per second by resolution, derived from upstream rates:
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
pub struct ArtcraftSeedance2p0CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0CostState {
pub fn from_request(request: &ArtcraftSeedance2p0RequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
// Everything else (including 720p) prices at 720p.
_ => CENTS_PER_SECOND_720P,
};
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 64);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 80);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 6, 1), 96);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 160);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 240);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 128);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 160);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 480);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 256);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 320);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 960);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
}
}
// -- 480p pricing --
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 31);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 39);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 78);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 117);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 78);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 155);
}
}
// -- 1080p pricing --
mod pricing_1080p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 187);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 233);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 466);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 699);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 466);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 933);
}
}
// -- Relative pricing --
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1);
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
assert!(c480 < c720);
assert!(c720 < c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1);
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
let b2 = cost_cents(Some(CommonResolution::TenEightyP), 5, 2);
let b4 = cost_cents(Some(CommonResolution::TenEightyP), 5, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// -- Credits equal cents --
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
None,
];
for res in resolutions {
for dur in [4, 5, 10, 15] {
for batch in [1, 2, 4] {
let cost = build_cost(res, dur, batch);
assert_eq!(
cost.cost_in_credits, cost.cost_in_usd_cents,
"credits should equal cents for res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// -- Cross-check with Kinovi via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
Some(CommonResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
assert_eq!(
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// -- Helpers --
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
fn build_cost(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cost_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count)
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,252 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0RequestState {
/// Final materialized request; ready to fire via the omni-gen video endpoint.
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = omni_gen_video_generate(
&client.api_host,
Some(&client.credentials),
self.request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Modality tests --
mod modality_tests {
use super::*;
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
#[tokio::test]
#[ignore]
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn keyframe_start_and_end_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn character_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Helpers --
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,426 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
resolve_image_ref, resolve_video_list_ref,
};
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take();
let start_frame = resolve_image_ref(builder.start_frame.take())?;
let end_frame = resolve_image_ref(builder.end_frame.take())?;
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModelEnum::Seedance2p0Fast),
idempotency_token: Some(idempotency_token),
prompt,
start_frame_image_media_token: start_frame,
end_frame_image_media_token: end_frame,
reference_image_media_tokens: reference_images,
reference_video_media_tokens: reference_videos,
reference_audio_media_tokens: reference_audio,
reference_character_tokens: reference_characters,
resolution,
aspect_ratio,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
generate_audio: None,
quality: None,
};
let state = ArtcraftSeedance2p0FastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)))
}
// -- Plan helpers --
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(CommonAspectRatioEnum::Square))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
_ => CommonAspectRatioEnum::Square,
}
}
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
// 1080p not supported for Fast
Some(CommonResolution::TenEightyP) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", CommonResolution::TenEightyP),
}))
}
_ => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
},
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::SevenTwentyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::SevenTwentyP,
}))
}
},
}
}
// Batch counts: 1, 2, 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 | 2 | 4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { 2 } else { 4 })
}
},
}
}
// Duration: 4-15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
// ── Field conversions ──
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn prompt_none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.prompt.is_none());
}
#[test]
fn model_is_seedance_2p0_fast() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Fast)));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
#[test]
fn wide() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
}
#[test]
fn tall() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
}
#[test]
fn square() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(CommonAspectRatio::Square);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.aspect_ratio.is_none());
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(CommonResolution::FourEightyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(CommonResolution::SevenTwentyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(CommonResolution::TenEightyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
resolution: Some(CommonResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Media tokens ──
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
];
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![
CharacterToken::new("char_a".to_string()),
CharacterToken::new("char_b".to_string()),
];
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0FastRequestState {
let result = build_artcraft_seedance_2p0_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
}
}
}
@@ -0,0 +1,260 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
// -- Pricing constants --
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We derive cents from the
// upstream credit rates and their credit-package prices, then set
// ArtCraft credits = cents.
/// USD cents per second by resolution, derived from upstream Fast rates:
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration * batch.
const CENTS_PER_SECOND_480P: f64 = 5.181;
const CENTS_PER_SECOND_720P: f64 = 12.727;
pub struct ArtcraftSeedance2p0FastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0FastCostState {
pub fn from_request(request: &ArtcraftSeedance2p0FastRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => CENTS_PER_SECOND_720P,
};
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 51);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 64);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 127);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 191);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 102);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 127);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 382);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 204);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 255);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 764);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
}
}
// -- 480p pricing --
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 21);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 26);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 52);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 78);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 52);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 104);
}
}
// -- Relative pricing --
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1);
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn cost_scales_with_duration() {
let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1);
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
let b2 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2);
let b4 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// -- Credits equal cents --
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
];
for res in resolutions {
for dur in [4, 5, 10, 15] {
for batch in [1, 2, 4] {
let cost = build_cost(res, dur, batch);
assert_eq!(
cost.cost_in_credits, cost.cost_in_usd_cents,
"credits should equal cents for res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// -- Cross-check with Kinovi Fast via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_fast_matches_kinovi_fast_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
assert_eq!(
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// -- Helpers --
fn build_cost(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cost_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count)
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,242 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0FastRequestState {
/// Final materialized request; ready to fire via the omni-gen video endpoint.
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0FastRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = omni_gen_video_generate(
&client.api_host,
Some(&client.credentials),
self.request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Modality tests --
mod modality_tests {
use super::*;
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Helpers --
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,460 @@
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
KinoviHappyHorse1p0AspectRatio, KinoviHappyHorse1p0BatchCount,
KinoviHappyHorse1p0OutputResolution,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::draft::{
KinoviHappyHorse1p0DraftState, KinoviHappyHorse1p0RemainingItems,
};
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_happy_horse_1p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = do_build(builder)?;
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft)))
}
fn do_build(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviHappyHorse1p0DraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let unhandled_request_state = KinoviHappyHorse1p0RemainingItems {
start_frame: builder.start_frame.take(),
};
Ok(KinoviHappyHorse1p0DraftState {
prompt,
aspect_ratio,
resolution,
duration_seconds,
batch_count,
unhandled_request_state: Some(unhandled_request_state),
})
}
// ── Plan helpers ──
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviHappyHorse1p0AspectRatio>, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(KinoviHappyHorse1p0AspectRatio::Square1x1))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(KinoviHappyHorse1p0AspectRatio::Portrait3x4)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviHappyHorse1p0AspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviHappyHorse1p0AspectRatio::Landscape4x3,
CommonAspectRatio::WideThreeByTwo => KinoviHappyHorse1p0AspectRatio::Landscape4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviHappyHorse1p0AspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviHappyHorse1p0AspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviHappyHorse1p0AspectRatio::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviHappyHorse1p0AspectRatio::Portrait9x16,
_ => KinoviHappyHorse1p0AspectRatio::Square1x1,
}
}
// Happy Horse supports 720p and 1080p only (no 480p).
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviHappyHorse1p0OutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::SevenTwentyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP))
}
},
}
}
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviHappyHorse1p0BatchCount>, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(None),
2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)),
4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two }))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four }))
}
},
}
}
// Happy Horse supports 4–15 seconds, defaults to 5.
fn plan_duration(
duration_seconds: Option<u16>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
let seconds = duration_seconds.unwrap_or(5);
Ok(seconds.clamp(4, 15) as u8)
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use super::*;
// ── Materialized field conversions ──
mod materialized_field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder()));
assert_eq!(draft.prompt, "a cat dancing");
}
#[test]
fn prompt_defaults_to_empty() {
let builder = GenerateVideoRequestBuilder { prompt: None, ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert_eq!(draft.prompt, "");
}
#[test]
fn duration_seconds_converted() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert_eq!(draft.duration_seconds, 10);
}
#[test]
fn duration_defaults_to_5() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert_eq!(draft.duration_seconds, 5);
}
#[test]
fn duration_clamped_to_max() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert_eq!(draft.duration_seconds, 15);
}
#[test]
fn batch_count_none_for_one() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(draft.batch_count.is_none());
}
#[test]
fn batch_count_two() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Two)));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Four)));
}
}
// ── Aspect ratio conversions ──
mod aspect_ratio_conversions {
use super::*;
#[test]
fn aspect_ratio_wide() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9)));
}
#[test]
fn aspect_ratio_tall() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16)));
}
#[test]
fn aspect_ratio_square() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Square1x1)));
}
#[test]
fn aspect_ratio_none_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(draft.aspect_ratio.is_none());
}
#[test]
fn aspect_ratio_auto_defaults_to_none() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Auto),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(draft.aspect_ratio.is_none());
}
#[test]
fn unsupported_aspect_ratio_falls_back() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideFiveByFour),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3)));
}
#[test]
fn unsupported_aspect_ratio_errors_out() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideFiveByFour),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..happy_horse_builder()
};
assert!(build_kinovi_happy_horse_1p0(builder).is_err());
}
}
// ── Resolution conversions ──
mod resolution_conversions {
use super::*;
#[test]
fn resolution_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::SevenTwentyP),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)));
}
#[test]
fn resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(draft.resolution.is_none());
}
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..happy_horse_builder()
};
assert!(build_kinovi_happy_horse_1p0(builder).is_err());
}
#[test]
fn unsupported_resolution_upgrades() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)));
}
#[test]
fn unsupported_resolution_downgrades() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)));
}
}
// ── unhandled_request_state ──
mod unhandled_request_state {
use super::*;
#[test]
fn unhandled_state_is_present() {
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder()));
assert!(draft.unhandled_request_state.is_some());
}
#[test]
fn start_frame_url_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg"));
}
#[test]
fn start_frame_media_token_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123"));
}
#[test]
fn empty_refs_are_none_in_unhandled() {
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder()));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_none());
}
}
// ── Full combination ──
#[test]
fn full_request_all_fields() {
let builder = GenerateVideoRequestBuilder {
prompt: Some("full test".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::TenEightyP),
duration_seconds: Some(10),
video_batch_count: Some(4),
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert_eq!(draft.prompt, "full test");
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16)));
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)));
assert_eq!(draft.duration_seconds, 10);
assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Four)));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_some());
}
// ── Helpers ──
fn happy_horse_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviHappyHorse1p0DraftState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft)
) => draft,
_ => panic!("expected KinoviHappyHorse1p0 draft"),
}
}
}
@@ -0,0 +1,344 @@
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
GenerateHappyHorse1p0Request, KinoviHappyHorse1p0BatchCount,
KinoviHappyHorse1p0OutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::draft::KinoviHappyHorse1p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
pub struct KinoviHappyHorse1p0CostState {
pub resolution: Option<KinoviHappyHorse1p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviHappyHorse1p0BatchCount>,
}
impl KinoviHappyHorse1p0CostState {
pub fn from_request(request: &KinoviHappyHorse1p0RequestState) -> Self {
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
}
}
pub fn from_draft(draft: &KinoviHappyHorse1p0DraftState) -> Self {
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let pricing_request = GenerateHappyHorse1p0Request {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// No impact on price
prompt: String::new(),
aspect_ratio: None,
start_frame_url: None,
};
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
// ── 720p pricing (33 credits/sec) ──
mod pricing_720p {
use super::*;
#[test]
fn credits_720p_every_duration() {
assert_eq!(credits(None, 3), 99);
assert_eq!(credits(None, 4), 132);
assert_eq!(credits(None, 5), 165);
assert_eq!(credits(None, 6), 198);
assert_eq!(credits(None, 7), 231);
assert_eq!(credits(None, 8), 264);
assert_eq!(credits(None, 9), 297);
assert_eq!(credits(None, 10), 330);
assert_eq!(credits(None, 11), 363);
assert_eq!(credits(None, 12), 396);
assert_eq!(credits(None, 13), 429);
assert_eq!(credits(None, 14), 462);
assert_eq!(credits(None, 15), 495);
}
#[test]
fn explicit_720p_same_as_default() {
let default = credits(None, 5);
let explicit = credits(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), 5);
assert_eq!(default, explicit);
}
#[test]
fn usd_cents_720p() {
// 165 credits / 193 * 100 = 85.49 → 85¢
assert_eq!(usd_cents(None, 5, None), 85);
// 495 credits / 193 * 100 = 256.48 → 256¢
assert_eq!(usd_cents(None, 15, None), 256);
}
}
// ── 1080p pricing (66 credits/sec) ──
mod pricing_1080p {
use super::*;
#[test]
fn credits_1080p_every_duration() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
assert_eq!(credits(r, 3), 198);
assert_eq!(credits(r, 4), 264);
assert_eq!(credits(r, 5), 330);
assert_eq!(credits(r, 6), 396);
assert_eq!(credits(r, 7), 462);
assert_eq!(credits(r, 8), 528);
assert_eq!(credits(r, 9), 594);
assert_eq!(credits(r, 10), 660);
assert_eq!(credits(r, 11), 726);
assert_eq!(credits(r, 12), 792);
assert_eq!(credits(r, 13), 858);
assert_eq!(credits(r, 14), 924);
assert_eq!(credits(r, 15), 990);
}
#[test]
fn usd_cents_1080p() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
// 330 credits / 193 * 100 = 170.98 → 171¢
assert_eq!(usd_cents(r, 5, None), 171);
// 990 credits / 193 * 100 = 512.95 → 513¢
assert_eq!(usd_cents(r, 15, None), 513);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_2_doubles_credits() {
let base = credits(None, 5);
let batch2 = credits_with_batch(None, 5, Some(KinoviHappyHorse1p0BatchCount::Two));
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples_credits() {
let base = credits(None, 5);
let batch4 = credits_with_batch(None, 5, Some(KinoviHappyHorse1p0BatchCount::Four));
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_applies_to_1080p() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
let base = credits(r, 5);
let batch4 = credits_with_batch(r, 5, Some(KinoviHappyHorse1p0BatchCount::Four));
assert_eq!(batch4, base * 4);
}
}
// ── Relative pricing ──
mod relative_pricing {
use super::*;
#[test]
fn r1080p_is_double_720p() {
for dur in 3..=15u8 {
let c720 = credits(None, dur);
let c1080 = credits(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), dur);
assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur);
}
}
#[test]
fn cost_scales_with_duration() {
let c3 = credits(None, 3);
let c10 = credits(None, 10);
let c15 = credits(None, 15);
assert!(c3 < c10);
assert!(c10 < c15);
}
#[test]
fn not_free() {
let est = KinoviHappyHorse1p0CostState {
resolution: None, duration_seconds: 5, batch_count: None,
}.estimate_cost();
assert!(!est.is_free);
}
}
// ── from_draft() ──
mod from_draft_tests {
use super::*;
#[test]
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None);
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 5);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(165));
}
#[test]
fn from_draft_1080p_batch_4() {
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP));
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330 * 4));
}
}
// ── from_request() ──
mod from_request_tests {
use super::*;
#[test]
fn from_request_default() {
let req = make_request_state(None, 5, None);
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(165));
}
#[test]
fn from_request_1080p() {
let req = make_request_state(
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), 5, None,
);
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
}
}
// ── Cross-check: draft matches request ──
mod cross_check {
use super::*;
#[test]
fn draft_and_request_produce_same_cost() {
let draft = make_draft(5, 1, Some(CommonResolution::SevenTwentyP));
let req = make_request_state(
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), 5, None,
);
let draft_cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviHappyHorse1p0CostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
#[test]
fn draft_and_request_produce_same_cost_1080p_batch_2() {
let draft = make_draft(10, 2, Some(CommonResolution::TenEightyP));
let req = make_request_state(
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), 10,
Some(KinoviHappyHorse1p0BatchCount::Two),
);
let draft_cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviHappyHorse1p0CostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
}
// ── Helpers ──
fn credits(
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
duration_seconds: u8,
) -> u64 {
credits_with_batch(resolution, duration_seconds, None)
}
fn credits_with_batch(
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
duration_seconds: u8,
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
) -> u64 {
KinoviHappyHorse1p0CostState { resolution, duration_seconds, batch_count }
.estimate_cost()
.cost_in_credits
.unwrap()
}
fn usd_cents(
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
duration_seconds: u8,
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
) -> u64 {
KinoviHappyHorse1p0CostState { resolution, duration_seconds, batch_count }
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
fn make_draft(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> KinoviHappyHorse1p0DraftState {
use crate::api::common_video_model::CommonVideoModel;
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::HappyHorse1p0,
provider: Provider::Seedance2Pro,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
match builder.build2().expect("build2 should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft)
) => draft,
_ => panic!("expected KinoviHappyHorse1p0 draft"),
}
}
fn make_request_state(
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
duration_seconds: u8,
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
) -> KinoviHappyHorse1p0RequestState {
KinoviHappyHorse1p0RequestState {
request: GenerateHappyHorse1p0Request {
prompt: "test".to_string(),
aspect_ratio: None,
output_resolution: resolution,
batch_count,
duration_seconds,
start_frame_url: None,
},
}
}
}
@@ -0,0 +1,54 @@
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
GenerateHappyHorse1p0Request, KinoviHappyHorse1p0AspectRatio,
KinoviHappyHorse1p0BatchCount, KinoviHappyHorse1p0OutputResolution,
};
use crate::api::image_ref::ImageRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video_v2::providers::kinovi::resolve::resolve_and_upload_single;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Debug, Clone)]
pub struct KinoviHappyHorse1p0DraftState {
pub prompt: String,
pub aspect_ratio: Option<KinoviHappyHorse1p0AspectRatio>,
pub resolution: Option<KinoviHappyHorse1p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviHappyHorse1p0BatchCount>,
pub unhandled_request_state: Option<KinoviHappyHorse1p0RemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviHappyHorse1p0RemainingItems {
pub start_frame: Option<ImageRef>,
}
impl KinoviHappyHorse1p0DraftState {
pub async fn to_request(
&mut self,
draft_context: &VideoGenerationDraftContext<'_>,
) -> Result<KinoviHappyHorse1p0RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut start_frame_url = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
}
let request = GenerateHappyHorse1p0Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
output_resolution: self.resolution,
batch_count: self.batch_count,
duration_seconds: self.duration_seconds,
start_frame_url,
};
Ok(KinoviHappyHorse1p0RequestState { request })
}
}

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