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
512 changed files with 13731 additions and 19146 deletions
@@ -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
@@ -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();
@@ -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.
@@ -41,6 +41,9 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "sora_2")]
Sora2,
@@ -52,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,
}))
}
@@ -49,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,
}))
}
@@ -47,7 +47,10 @@ 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;
@@ -141,7 +144,10 @@ 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,
@@ -151,7 +157,10 @@ impl GenerateVideoRequestBuilder {
// 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(),
@@ -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)]
@@ -71,10 +71,15 @@ generate_video_v2/
├── 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)
│ ├── 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
│ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
│ ├── 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
@@ -2,4 +2,4 @@ pub mod providers;
pub mod video_generation_request;
pub mod video_generation_draft;
pub mod video_generation_draft_or_request;
mod video_generation_draft_context;
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
}
}
@@ -1 +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),
}
}
@@ -1,19 +1,18 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
Seedance2p0OutputResolution,
};
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::audio_list_ref::AudioListRef;
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::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_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;
@@ -22,129 +21,66 @@ pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) ->
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let output_resolution = plan_output_resolution(builder.resolution.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_media_token = resolve_image_ref(builder.start_frame.take())?;
let end_frame_media_token = resolve_image_ref(builder.end_frame.take())?;
let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?;
let reference_video_media_tokens = resolve_video_list_ref(builder.reference_videos.take())?;
let reference_audio_media_tokens = resolve_audio_list_ref(builder.reference_audio.take())?;
let reference_character_tokens = resolve_character_list_ref(builder.reference_character_tokens.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 = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: idempotency_token,
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModelEnum::Seedance2p0),
idempotency_token: Some(idempotency_token),
prompt,
start_frame_media_token,
end_frame_media_token,
reference_image_media_tokens,
reference_video_media_tokens,
reference_audio_media_tokens,
reference_character_tokens,
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,
output_resolution,
duration_seconds,
batch_count: Some(batch_count),
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)))
}
// ── Resolve helpers ──
// -- Plan helpers --
fn resolve_image_ref(
image_ref: Option<ImageRef>,
) -> Result<Option<tokens::tokens::media_files::MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_video_list_ref(
video_list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_audio_list_ref(
audio_list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_character_list_ref(
character_list_ref: Option<CharacterListRef>,
) -> Option<Vec<tokens::tokens::characters::CharacterToken>> {
match character_list_ref {
None => None,
Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
// ── Plan helpers ──
// Supported aspect ratios and their AR values (width / height):
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — let the model decide
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(Seedance2p0AspectRatio::Square1x1))
Ok(Some(CommonAspectRatioEnum::Square))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -160,16 +96,15 @@ fn plan_aspect_ratio(
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => Seedance2p0AspectRatio::Square1x1,
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,
}
}
@@ -177,16 +112,14 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRat
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0OutputResolution>, ArtcraftRouterError> {
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP) => Ok(Some(Seedance2p0OutputResolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance2p0OutputResolution::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance2p0OutputResolution::TenEightyP)),
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -196,31 +129,29 @@ fn plan_output_resolution(
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
_ => Seedance2p0OutputResolution::TenEightyP,
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
_ => Seedance2p0OutputResolution::TenEightyP,
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
},
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
// Batch counts: 1, 2, 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(Seedance2p0BatchCount::One),
2 => Ok(Seedance2p0BatchCount::Two),
4 => Ok(Seedance2p0BatchCount::Four),
1 | 2 | 4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -228,17 +159,15 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
Ok(if count < 4 { 2 } else { 4 })
}
},
}
}
// Seedance2p0 supports duration of 4–15 seconds.
// Duration: 4-15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -262,350 +191,150 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0OutputResolution,
};
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::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::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::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;
use super::*;
// ── Materialized field conversions ──
// ── Field conversions ──
mod materialized_field_conversions {
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
assert_eq!(state.request.prompt.as_deref(), Some("a cat dancing"));
fn model_is_seedance_2p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0)));
}
#[test]
fn prompt_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { prompt: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.prompt.is_none());
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_seconds_converted() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.duration_seconds, Some(10));
}
#[test]
fn duration_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.duration_seconds.is_none());
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 builder = GenerateVideoRequestBuilder {
duration_seconds: Some(99),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.duration_seconds, Some(15));
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_one() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::One)));
}
#[test]
fn batch_count_two() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Two)));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
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));
}
}
// ── Aspect ratio conversions ──
// ── Resolution ──
mod aspect_ratio_conversions {
mod resolution_tests {
use super::*;
#[test]
fn aspect_ratio_wide() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
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 aspect_ratio_tall() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
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 aspect_ratio_square() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
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 aspect_ratio_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.aspect_ratio.is_none());
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Resolution conversions ──
// ── Media tokens ──
mod resolution_conversions {
mod media_token_tests {
use super::*;
#[test]
fn resolution_480p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::FourEightyP)));
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 resolution_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::SevenTwentyP),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::SevenTwentyP)));
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 resolution_1080p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
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 resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.output_resolution.is_none());
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));
}
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..artcraft_builder()
};
assert!(build_artcraft_seedance_2p0(builder).is_err());
}
#[test]
fn unsupported_resolution_rounds_up() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
}
}
// ── Media token resolution ──
mod media_token_resolution {
use super::*;
#[test]
fn start_frame_media_token() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start123".to_string()))),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.start_frame_media_token.as_ref().unwrap().as_str(), "mf_start123");
}
#[test]
fn end_frame_media_token() {
let builder = GenerateVideoRequestBuilder {
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end456".to_string()))),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.end_frame_media_token.as_ref().unwrap().as_str(), "mf_end456");
}
#[test]
fn reference_image_media_tokens() {
let builder = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref1".to_string()),
MediaFileToken::new("mf_ref2".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
let tokens = state.request.reference_image_media_tokens.unwrap();
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].as_str(), "mf_ref1");
assert_eq!(tokens[1].as_str(), "mf_ref2");
}
#[test]
fn reference_video_media_tokens() {
let builder = GenerateVideoRequestBuilder {
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_vid1".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
let tokens = state.request.reference_video_media_tokens.unwrap();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].as_str(), "mf_vid1");
}
#[test]
fn character_tokens() {
let builder = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
CharacterToken::new("char_def".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
let tokens = state.request.reference_character_tokens.unwrap();
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].as_str(), "char_abc");
assert_eq!(tokens[1].as_str(), "char_def");
}
#[test]
fn url_image_ref_returns_error() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..artcraft_builder()
};
assert!(matches!(
build_artcraft_seedance_2p0(builder),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn empty_refs_are_none() {
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
assert!(state.request.start_frame_media_token.is_none());
assert!(state.request.end_frame_media_token.is_none());
assert!(state.request.reference_image_media_tokens.is_none());
assert!(state.request.reference_video_media_tokens.is_none());
assert!(state.request.reference_audio_media_tokens.is_none());
assert!(state.request.reference_character_tokens.is_none());
}
}
// ── Returns Request, not Draft ──
#[test]
fn build_returns_request_variant() {
let result = build_artcraft_seedance_2p0(artcraft_builder()).expect("build should succeed");
assert!(matches!(result, VideoGenerationDraftOrRequest::Request(_)));
}
// ── 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::MediaFileToken(MediaFileToken::new("mf_start".to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))),
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_img".to_string())])),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_vid".to_string())])),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_xyz".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.prompt.as_deref(), Some("full test"));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
assert_eq!(state.request.duration_seconds, Some(10));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
assert!(state.request.start_frame_media_token.is_some());
assert!(state.request.end_frame_media_token.is_some());
assert!(state.request.reference_image_media_tokens.is_some());
assert!(state.request.reference_video_media_tokens.is_some());
assert!(state.request.reference_character_tokens.is_some());
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_request(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> ArtcraftSeedance2p0RequestState {
match result.expect("build should succeed") {
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,
@@ -1,6 +1,4 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0BatchCount, Seedance2p0OutputResolution,
};
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;
@@ -13,7 +11,7 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::reque
// 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:
/// 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
@@ -25,19 +23,18 @@ const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
pub struct ArtcraftSeedance2p0CostState {
pub resolution: Seedance2p0OutputResolution,
pub duration_seconds: u8,
pub batch_count: Seedance2p0BatchCount,
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.output_resolution
.unwrap_or(Seedance2p0OutputResolution::SevenTwentyP);
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.batch_count
.unwrap_or(Seedance2p0BatchCount::One);
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());
@@ -47,18 +44,13 @@ impl ArtcraftSeedance2p0CostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
Seedance2p0OutputResolution::FourEightyP => CENTS_PER_SECOND_480P,
Seedance2p0OutputResolution::SevenTwentyP => CENTS_PER_SECOND_720P,
Seedance2p0OutputResolution::TenEightyP => CENTS_PER_SECOND_1080P,
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
// Everything else (including 720p) prices at 720p.
_ => CENTS_PER_SECOND_720P,
};
let batch_multiplier: f64 = match self.batch_count {
Seedance2p0BatchCount::One => 1.0,
Seedance2p0BatchCount::Two => 2.0,
Seedance2p0BatchCount::Four => 4.0,
};
let usd_cents = (self.duration_seconds as f64 * cents_per_second * batch_multiplier).round() as u64;
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 {
@@ -74,208 +66,164 @@ impl ArtcraftSeedance2p0CostState {
#[cfg(test)]
mod tests {
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
};
use tokens::tokens::media_files::MediaFileToken;
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::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use super::*;
// ── 720p pricing ──
// -- 720p pricing --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One), 64);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 6, Seedance2p0BatchCount::One), 96);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 7, Seedance2p0BatchCount::One), 112);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One), 160);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One), 240);
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 cost_720p_batch_2() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Two), 128);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Two), 160);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Two), 480);
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 cost_720p_batch_4() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Four), 256);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Four), 320);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Four), 960);
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 ──
// -- 480p pricing --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 4, Seedance2p0BatchCount::One), 31);
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 10, Seedance2p0BatchCount::One), 78);
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 15, Seedance2p0BatchCount::One), 117);
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 cost_480p_batch_2() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Two), 78);
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 78);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Four), 155);
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 155);
}
}
// ── 1080p pricing ──
// -- 1080p pricing --
mod pricing_1080p {
use super::*;
#[test]
fn cost_1080p_batch_1() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 4, Seedance2p0BatchCount::One), 187);
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 10, Seedance2p0BatchCount::One), 466);
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 15, Seedance2p0BatchCount::One), 699);
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 cost_1080p_batch_2() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two), 466);
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 466);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four), 933);
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 933);
}
}
// ── Relative pricing ──
// -- Relative pricing --
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One);
let c720 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One);
let c1080 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
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 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One);
let c10 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One);
let c15 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One);
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 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
let b2 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two);
let b4 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four);
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);
}
}
// ── Video reference does NOT affect cost (yet) ──
// -- Credits equal cents --
#[test]
fn video_reference_does_not_affect_cost() {
let base = ArtcraftSeedance2p0CostState {
resolution: Seedance2p0OutputResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: Seedance2p0BatchCount::One,
has_video_reference: false,
};
let without = base.estimate_cost();
let with = ArtcraftSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
assert_eq!(without.cost_in_credits, with.cost_in_credits);
}
// ── from_request() tests ──
mod from_request_tests {
mod credits_tests {
use super::*;
#[test]
fn from_request_720p() {
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Seedance2p0BatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, Seedance2p0BatchCount::One, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(Seedance2p0OutputResolution::FourEightyP), 5, Seedance2p0BatchCount::One, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
#[test]
fn from_request_1080p_batch_2() {
let req = make_request_state(Some(Seedance2p0OutputResolution::TenEightyP), 5, Seedance2p0BatchCount::Two, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
}
#[test]
fn from_request_with_video_reference() {
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, true);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
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: Artcraft v2 matches Kinovi v2 via GenerateVideoRequestBuilder ──
mod cross_check_with_kinovi_via_builder {
use crate::api::common_video_model::CommonVideoModel;
// -- Cross-check with Kinovi via builder --
mod cross_check_with_kinovi {
use super::*;
/// Combinatorial test: build the same request with Provider::Artcraft and
/// Provider::Seedance2Pro, then compare USD cents. They must match for all
/// resolution × duration × batch combinations.
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None, // defaults to 720p
None,
Some(CommonResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
@@ -284,39 +232,24 @@ mod tests {
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let artcraft_cost = artcraft.build2()
.expect("artcraft build2 should succeed")
.estimate_cost()
.expect("artcraft estimate_cost should succeed");
let kinovi_cost = kinovi.build2()
.expect("kinovi build2 should succeed")
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost should succeed");
.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={} — artcraft={:?}, kinovi={:?}",
"USD cents mismatch: res={:?} dur={}s batch={}",
res, dur, batch,
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
);
}
}
@@ -324,208 +257,34 @@ mod tests {
}
}
// ── Cross-check: Artcraft v2 matches v1 estimate_video_cost_artcraft_seedance2p0 ──
// -- Helpers --
mod cross_check_with_v1 {
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio;
use tokens::tokens::media_files::MediaFileToken;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use super::*;
/// Combinatorial test: compare v2 ArtcraftSeedance2p0CostState against
/// v1 estimate_video_cost_artcraft_seedance2p0 for all resolution ×
/// duration × batch combinations.
#[test]
fn v2_matches_v1_all_combos() {
let resolutions = [
(Some(CommonResolution::FourEightyP), Seedance2p0OutputResolution::FourEightyP),
(Some(CommonResolution::SevenTwentyP), Seedance2p0OutputResolution::SevenTwentyP),
(None, Seedance2p0OutputResolution::SevenTwentyP), // None defaults to 720p
(Some(CommonResolution::TenEightyP), Seedance2p0OutputResolution::TenEightyP),
];
let durations: [u8; 4] = [4, 5, 10, 15];
let batches = [
(Seedance2p0BatchCount::One, Seedance2p0BatchCount::One),
(Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Two),
(Seedance2p0BatchCount::Four, Seedance2p0BatchCount::Four),
];
for (v1_res, v2_res) in &resolutions {
for dur in &durations {
for (v1_batch, v2_batch) in &batches {
// v1: construct a PlanArtcraftSeedance2p0 and call the v1 cost function
let v1_plan = PlanArtcraftSeedance2p0 {
prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_characters: None,
aspect_ratio: Some(Seedance2p0AspectRatio::Square1x1),
resolution: *v1_res,
duration_seconds: Some(*dur),
batch_count: *v1_batch,
idempotency_token: "test".to_string(),
};
let v1_cost = estimate_video_cost_artcraft_seedance2p0(&v1_plan);
// v2: construct cost state directly
let v2_cost = ArtcraftSeedance2p0CostState {
resolution: *v2_res,
duration_seconds: *dur,
batch_count: *v2_batch,
has_video_reference: false,
}.estimate_cost();
assert_eq!(
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
v1_res, dur, v1_batch,
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
);
assert_eq!(
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
"Credits mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
v1_res, dur, v1_batch,
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
);
}
}
}
}
}
// ── Credits spot checks ──
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
// ArtCraft credits: 100 credits = $1.00, so credits always equal cents.
for res in [
Seedance2p0OutputResolution::FourEightyP,
Seedance2p0OutputResolution::SevenTwentyP,
Seedance2p0OutputResolution::TenEightyP,
] {
for dur in [4, 5, 10, 15] {
for batch in [Seedance2p0BatchCount::One, Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Four] {
assert_eq!(
credits(res, dur, batch),
usd_cents(res, dur, batch),
"credits should equal usd_cents for {:?} {}s {:?}", res, dur, batch,
);
}
}
}
}
#[test]
fn credits_720p() {
assert_eq!(credits(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
}
#[test]
fn credits_480p() {
assert_eq!(credits(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
}
#[test]
fn credits_1080p() {
assert_eq!(credits(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
}
}
// ── Builder round-trip ──
mod builder_round_trip {
use super::*;
#[test]
fn build2_then_estimate_cost_720p() {
let builder = GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
let result = builder.build2().expect("build2 should succeed");
let cost = result.estimate_cost().expect("estimate_cost should succeed");
assert_eq!(cost.cost_in_usd_cents, Some(80));
}
#[test]
fn build2_then_estimate_cost_1080p_batch_4() {
let builder = GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
resolution: Some(CommonResolution::TenEightyP),
duration_seconds: Some(5),
video_batch_count: Some(4),
..Default::default()
};
let result = builder.build2().expect("build2 should succeed");
let cost = result.estimate_cost().expect("estimate_cost should succeed");
assert_eq!(cost.cost_in_usd_cents, Some(933));
}
}
// ── Helpers ──
fn usd_cents(
resolution: Seedance2p0OutputResolution,
duration_seconds: u8,
batch_count: Seedance2p0BatchCount,
) -> u64 {
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
/// 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()
}
fn credits(
resolution: Seedance2p0OutputResolution,
duration_seconds: u8,
batch_count: Seedance2p0BatchCount,
) -> u64 {
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_credits
.unwrap()
}
fn make_request_state(
resolution: Option<Seedance2p0OutputResolution>,
duration_seconds: u8,
batch_count: Seedance2p0BatchCount,
with_video_ref: bool,
) -> ArtcraftSeedance2p0RequestState {
let reference_video_media_tokens = if with_video_ref {
Some(vec![MediaFileToken::new("mf_testvid".to_string())])
} else {
None
};
ArtcraftSeedance2p0RequestState {
request: Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: "test-idem".to_string(),
prompt: Some("test".to_string()),
start_frame_media_token: None,
end_frame_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens,
reference_audio_media_tokens: None,
reference_character_tokens: None,
aspect_ratio: None,
output_resolution: resolution,
duration_seconds: Some(duration_seconds),
batch_count: Some(batch_count),
},
}
}
}
@@ -1,5 +1,5 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
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;
@@ -10,13 +10,13 @@ use crate::generate::generate_video::generate_video_response::{
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0RequestState {
/// Final materialized request; ready to fire.
pub request: Seedance2p0MultiFunctionVideoGenRequest,
/// 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 = seedance_2p0_multi_function_video_gen(
let response = omni_gen_video_generate(
&client.api_host,
Some(&client.credentials),
self.request.clone(),
@@ -25,8 +25,8 @@ impl ArtcraftSeedance2p0RequestState {
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: response.all_inference_job_tokens,
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
}
@@ -42,7 +42,6 @@ mod tests {
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::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
@@ -58,14 +57,13 @@ mod tests {
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// ── Aspect ratio tests ──
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
@@ -77,7 +75,7 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
@@ -89,7 +87,7 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
@@ -101,13 +99,13 @@ mod tests {
}
}
// ── Resolution tests ──
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
@@ -119,7 +117,7 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
@@ -131,7 +129,7 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
@@ -143,13 +141,14 @@ mod tests {
}
}
// ── Modality tests ──
// -- 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
#[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()),
@@ -161,10 +160,8 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn keyframe_start_and_end_frame() {
// Artcraft uses media file tokens directly — no URL resolution needed.
// Tokens from test_data::web::image_urls comments.
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()))),
@@ -177,15 +174,13 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
#[ignore]
async fn image_references() {
// Artcraft uses media file tokens directly.
// Tokens from test_data::web::image_urls comments.
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(JUNO_AT_LAKE_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),
@@ -196,25 +191,8 @@ mod tests {
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn video_references() {
// TODO: Needs a real video media file token to test properly.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()), // Using an image token as placeholder
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_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
#[ignore]
async fn character_references() {
// Character tokens are passed through directly to the Artcraft API.
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![
@@ -227,11 +205,9 @@ mod tests {
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// TODO: Add audio reference test once a media file token for a test audio file is available.
}
// ── Helpers ──
// -- Helpers --
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
@@ -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 })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,256 @@
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
generate_happy_horse_1p0, GenerateHappyHorse1p0Args, GenerateHappyHorse1p0Request,
};
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::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
#[derive(Debug, Clone)]
pub struct KinoviHappyHorse1p0RequestState {
pub request: GenerateHappyHorse1p0Request,
}
impl KinoviHappyHorse1p0RequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateHappyHorse1p0Args {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_happy_horse_1p0(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
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,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
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_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
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_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
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),
..happy_horse_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..happy_horse_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..happy_horse_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..happy_horse_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..happy_horse_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..happy_horse_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn keyframe_start_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog watches the sunset over the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..happy_horse_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn keyframe_1080p_square() {
let start_token = MediaFileToken::new("mf_start".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("A dragon soaring over the mountains.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::TenEightyP),
duration_seconds: Some(15),
..happy_horse_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn happy_horse_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::HappyHorse1p0,
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,3 +1,4 @@
pub mod happy_horse_1p0;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub(crate) mod resolve;
@@ -27,6 +27,8 @@ impl KinoviSeedance2p0RequestState {
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,
}))
}
}
@@ -27,6 +27,8 @@ impl KinoviSeedance2p0FastRequestState {
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,6 +1,8 @@
use crate::client::router_client::RouterClient;
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::draft::KinoviHappyHorse1p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
@@ -13,15 +15,25 @@ use crate::generate::generate_video_v2::video_generation_request::VideoGeneratio
*/
#[derive(Clone, Debug)]
pub enum VideoGenerationDraftRequest {
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
}
impl VideoGenerationDraftRequest {
pub fn get_provider(&self) -> Provider {
match self {
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
}
@@ -31,6 +43,10 @@ impl VideoGenerationDraftRequest {
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
},
VideoGenerationDraftRequest::KinoviSeedance2p0(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviSeedance2p0(result))
@@ -3,6 +3,7 @@ use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use std::collections::HashMap;
use std::fmt::Debug;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -37,3 +38,13 @@ impl <'a> VideoGenerationDraftContext<'a> {
.ok_or_else(|| ArtcraftRouterError::Client(ClientError::CharacterTokenToKinoviCharacterIdNotProvided))
}
}
impl Debug for VideoGenerationDraftContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VideoGenerationDraftContext")
.field("client", &self.client.is_some())
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
.field("character_token_to_kinovi_id_map", &self.character_token_to_kinovi_id_map)
.finish()
}
}
@@ -1,8 +1,10 @@
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
#[derive(Debug, Clone)]
pub enum VideoGenerationDraftOrRequest {
Draft(VideoGenerationDraftRequest),
Request(VideoGenerationRequest),
@@ -10,6 +12,13 @@ pub enum VideoGenerationDraftOrRequest {
impl VideoGenerationDraftOrRequest {
pub fn get_provider(&self) -> Provider {
match self {
Self::Draft(draft) => draft.get_provider(),
Self::Request(request) => request.get_provider(),
}
}
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
@@ -1,9 +1,16 @@
use crate::api::provider::Provider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::cost::ArtcraftSeedance2p0CostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
@@ -11,17 +18,34 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re
#[derive(Clone, Debug)]
pub enum VideoGenerationRequest {
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
}
impl VideoGenerationRequest {
pub fn get_provider(&self) -> Provider {
match self {
Self::ArtcraftHappyHorse1p0(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0Fast(_) => Provider::Artcraft,
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
}
@@ -31,10 +55,22 @@ impl VideoGenerationRequest {
/// If successful, returns the job IDs.
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match self {
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviSeedance2p0(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
@@ -0,0 +1 @@
pub mod video;
@@ -0,0 +1,637 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs
, WorkflowRunTaskRequest,
};
// ── Args ──
pub struct GenerateHappyHorse1p0Args<'a> {
pub request: GenerateHappyHorse1p0Request,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateHappyHorse1p0Request {
pub prompt: String,
pub aspect_ratio: Option<KinoviHappyHorse1p0AspectRatio>,
pub output_resolution: Option<KinoviHappyHorse1p0OutputResolution>,
pub batch_count: Option<KinoviHappyHorse1p0BatchCount>,
pub duration_seconds: u8,
pub start_frame_url: Option<String>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum KinoviHappyHorse1p0AspectRatio {
Portrait9x16,
Portrait3x4,
Square1x1,
Landscape4x3,
Landscape16x9,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviHappyHorse1p0OutputResolution {
SevenTwentyP,
TenEightyP,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviHappyHorse1p0BatchCount {
One,
Two,
Four,
}
// ── Pricing ──
//
// Happy Horse 1.0 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).
impl GenerateHappyHorse1p0Request {
/// Estimate the credit cost for this generation request.
pub fn estimate_credits(&self) -> u32 {
let credits_per_second: u32 = match self.output_resolution {
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => 66,
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => 33,
};
let per_video = u32::from(self.duration_seconds) * credits_per_second;
let batch_multiplier: u32 = match self.batch_count {
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
};
per_video * batch_multiplier
}
/// Credits per dollar for billing conversion.
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
fn credits_per_dollar() -> f64 {
193.0
}
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let credits = self.estimate_credits() as f64;
let cost = credits / Self::credits_per_dollar() * 100.0;
cost.round() as u64
}
}
// ── Response ──
pub struct GenerateHappyHorse1p0Response {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_happy_horse_1p0(
args: GenerateHappyHorse1p0Args<'_>,
) -> Result<GenerateHappyHorse1p0Response, Seedance2ProError> {
let req = args.request;
let raw_request = WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::HappyHorse1p0,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: Some(false),
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
request: raw_request,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateHappyHorse1p0Response {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Mapping helpers ──
fn map_aspect_ratio(ar: Option<KinoviHappyHorse1p0AspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviHappyHorse1p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3) => KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviHappyHorse1p0AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
None => KinoviAspectRatioRaw::Landscape16x9,
}
}
fn map_output_resolution(res: KinoviHappyHorse1p0OutputResolution) -> KinoviOutputResolutionRaw {
match res {
KinoviHappyHorse1p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviHappyHorse1p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
}
}
fn map_batch_count(bc: Option<KinoviHappyHorse1p0BatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod pricing_tests {
use super::*;
fn make_request(
duration_seconds: u8,
output_resolution: Option<KinoviHappyHorse1p0OutputResolution>,
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
) -> GenerateHappyHorse1p0Request {
GenerateHappyHorse1p0Request {
prompt: String::new(),
aspect_ratio: None,
output_resolution,
batch_count,
duration_seconds,
start_frame_url: None,
}
}
fn r720(dur: u8) -> GenerateHappyHorse1p0Request {
make_request(dur, None, None)
}
fn r1080(dur: u8) -> GenerateHappyHorse1p0Request {
make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None)
}
// ── 720p credits (33 credits/sec) ──
mod credits_720p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r720(3).estimate_credits(), 99);
assert_eq!(r720(4).estimate_credits(), 132);
assert_eq!(r720(5).estimate_credits(), 165);
assert_eq!(r720(6).estimate_credits(), 198);
assert_eq!(r720(7).estimate_credits(), 231);
assert_eq!(r720(8).estimate_credits(), 264);
assert_eq!(r720(9).estimate_credits(), 297);
assert_eq!(r720(10).estimate_credits(), 330);
assert_eq!(r720(11).estimate_credits(), 363);
assert_eq!(r720(12).estimate_credits(), 396);
assert_eq!(r720(13).estimate_credits(), 429);
assert_eq!(r720(14).estimate_credits(), 462);
assert_eq!(r720(15).estimate_credits(), 495);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).estimate_credits();
let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).estimate_credits();
assert_eq!(default, explicit);
}
}
// ── 1080p credits (66 credits/sec) ──
mod credits_1080p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r1080(3).estimate_credits(), 198);
assert_eq!(r1080(4).estimate_credits(), 264);
assert_eq!(r1080(5).estimate_credits(), 330);
assert_eq!(r1080(6).estimate_credits(), 396);
assert_eq!(r1080(7).estimate_credits(), 462);
assert_eq!(r1080(8).estimate_credits(), 528);
assert_eq!(r1080(9).estimate_credits(), 594);
assert_eq!(r1080(10).estimate_credits(), 660);
assert_eq!(r1080(11).estimate_credits(), 726);
assert_eq!(r1080(12).estimate_credits(), 792);
assert_eq!(r1080(13).estimate_credits(), 858);
assert_eq!(r1080(14).estimate_credits(), 924);
assert_eq!(r1080(15).estimate_credits(), 990);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_1_is_base() {
let base = r720(5).estimate_credits();
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).estimate_credits();
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).estimate_credits();
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).estimate_credits();
let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
let base = r1080(5).estimate_credits();
let batch2 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
let batch4 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
}
// ── Relative pricing ──
mod relative_tests {
use super::*;
#[test]
fn r1080p_is_exactly_double_720p() {
for dur in 3..=15u8 {
let c720 = make_request(dur, None, None).estimate_credits();
let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).estimate_credits();
assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur);
}
}
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).estimate_credits();
let c10 = r720(10).estimate_credits();
let c15 = r720(15).estimate_credits();
assert!(c3 < c10);
assert!(c10 < c15);
}
}
// ── USD cents ──
mod usd_cents_tests {
use super::*;
#[test]
fn credits_per_dollar_is_193() {
assert_eq!(GenerateHappyHorse1p0Request::credits_per_dollar(), 193.0);
}
#[test]
fn usd_cents_720p_5s() {
// 165 credits / 193 * 100 = 85.49 → 85¢
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 85);
}
#[test]
fn usd_cents_1080p_5s() {
// 330 credits / 193 * 100 = 170.98 → 171¢
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 171);
}
#[test]
fn usd_cents_720p_15s() {
// 495 credits / 193 * 100 = 256.48 → 256¢
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 256);
}
#[test]
fn usd_cents_1080p_15s() {
// 990 credits / 193 * 100 = 512.95 → 513¢
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 513);
}
#[test]
fn batch_multiplies_usd_cents() {
let base = r720(5).estimate_cost_in_usd_cents();
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_cost_in_usd_cents();
// Batch 2 should be approximately 2× base (rounding may differ slightly)
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
"batch 2 ({}) should be ~2× base ({})", batch2, base);
}
}
// ── Aspect ratio doesn't affect cost ──
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).estimate_credits();
let ratios = [
KinoviHappyHorse1p0AspectRatio::Landscape16x9,
KinoviHappyHorse1p0AspectRatio::Portrait9x16,
KinoviHappyHorse1p0AspectRatio::Square1x1,
KinoviHappyHorse1p0AspectRatio::Landscape4x3,
KinoviHappyHorse1p0AspectRatio::Portrait3x4,
];
for ar in &ratios {
let req = GenerateHappyHorse1p0Request {
prompt: String::new(),
aspect_ratio: Some(*ar),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
};
assert_eq!(
req.estimate_credits(), baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
}
}
mod text_to_video {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_default() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
aspect_ratio: None,
output_resolution: None,
batch_count: None,
duration_seconds: 4,
start_frame_url: None,
},
}).await?;
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: "A golden retriever running through a field of sunflowers".to_string(),
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9),
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
},
}).await?;
println!("t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_1080p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: "A dragon soaring over a medieval castle at sunset".to_string(),
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9),
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
batch_count: None,
duration_seconds: 4,
start_frame_url: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod aspect_ratios {
use super::*;
async fn test_aspect_ratio(ar: KinoviHappyHorse1p0AspectRatio, label: &str) -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: format!("A cat sitting in a sunbeam ({})", label),
aspect_ratio: Some(ar),
output_resolution: None,
batch_count: None,
duration_seconds: 4,
start_frame_url: None,
},
}).await?;
println!("{} — task_id={}, order_id={}", label, result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_landscape_16x9() -> AnyhowResult<()> {
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Landscape16x9, "16:9").await
}
#[tokio::test]
#[ignore]
async fn test_portrait_9x16() -> AnyhowResult<()> {
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Portrait9x16, "9:16").await
}
#[tokio::test]
#[ignore]
async fn test_square_1x1() -> AnyhowResult<()> {
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Square1x1, "1:1").await
}
#[tokio::test]
#[ignore]
async fn test_standard_4x3() -> AnyhowResult<()> {
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Landscape4x3, "4:3").await
}
#[tokio::test]
#[ignore]
async fn test_portrait_3x4() -> AnyhowResult<()> {
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Portrait3x4, "3:4").await
}
}
mod keyframe {
use super::*;
#[tokio::test]
#[ignore]
async fn test_keyframe_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session).await?;
println!("Uploaded start frame: {}", start_frame_url);
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: "The corgi dog watches the lake as the sun sets.".to_string(),
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16),
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
batch_count: None,
duration_seconds: 8,
start_frame_url: Some(start_frame_url),
},
}).await?;
println!("keyframe 720p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_1080p_square() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session).await?;
println!("Uploaded start frame: {}", start_frame_url);
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: "A dragon and a raptor fighting on the beach.".to_string(),
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Square1x1),
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
batch_count: None,
duration_seconds: 15,
start_frame_url: Some(start_frame_url),
},
}).await?;
println!("keyframe 1080p square — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_landscape_default_resolution() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session).await?;
println!("Uploaded start frame: {}", start_frame_url);
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
session: &session,
host_override: None,
request: GenerateHappyHorse1p0Request {
prompt: "The dog runs along the shore, kicking up sand.".to_string(),
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: Some(start_frame_url),
},
}).await?;
println!("keyframe landscape default res — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
async fn upload_test_image(session: &Seedance2ProSession) -> AnyhowResult<String> {
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
).await?;
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "jpg".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: image_bytes,
host_override: None,
}).await?;
Ok(upload_result.public_url)
}
}
@@ -0,0 +1,758 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
WorkflowRunTaskRequest,
};
// ── Args ──
pub struct GenerateSeedance2p0Args<'a> {
pub request: GenerateSeedance2p0Request,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateSeedance2p0Request {
pub prompt: String,
pub aspect_ratio: Option<KinoviSeedance2p0AspectRatio>,
pub output_resolution: Option<KinoviSeedance2p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0BatchCount>,
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 character_ids: Option<Vec<String>>,
pub use_face_blur_hack: Option<bool>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0AspectRatio {
Landscape16x9,
Portrait9x16,
Square1x1,
Standard4x3,
Portrait3x4,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0OutputResolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0BatchCount {
One,
Two,
Four,
}
// ── Pricing ──
//
// Seedance 2.0 Pro credit pricing:
//
// | Resolution | Credits/sec |
// |------------|-------------|
// | 480p | 15 |
// | 720p | 40 |
// | 1080p | 90 |
//
// 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).
impl GenerateSeedance2p0Request {
/// Estimate the credit cost for this generation request.
pub fn estimate_credits(&self) -> u32 {
let credits_per_second: u32 = match self.output_resolution {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90,
};
let per_video = u32::from(self.duration_seconds) * credits_per_second;
let batch_multiplier: u32 = match self.batch_count {
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
Some(KinoviSeedance2p0BatchCount::Two) => 2,
Some(KinoviSeedance2p0BatchCount::Four) => 4,
};
per_video * batch_multiplier
}
/// Credits per dollar for billing conversion.
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
fn credits_per_dollar() -> f64 {
193.0
}
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let credits = self.estimate_credits() as f64;
let cost = credits / Self::credits_per_dollar() * 100.0;
cost.round() as u64
}
}
// ── Response ──
pub struct GenerateSeedance2p0Response {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedance_2p0(
args: GenerateSeedance2p0Args<'_>,
) -> Result<GenerateSeedance2p0Response, Seedance2ProError> {
let req = args.request;
let raw_request = WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2Pro,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
end_frame_url: req.end_frame_url,
reference_image_urls: req.reference_image_urls,
reference_video_urls: req.reference_video_urls,
reference_audio_urls: req.reference_audio_urls,
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
request: raw_request,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedance2p0Response {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Mapping helpers ──
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0AspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviSeedance2p0AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
None => KinoviAspectRatioRaw::Landscape16x9,
}
}
fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p0BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod pricing_tests {
use super::*;
fn build_request(
duration_seconds: u8,
output_resolution: Option<KinoviSeedance2p0OutputResolution>,
batch_count: Option<KinoviSeedance2p0BatchCount>,
) -> GenerateSeedance2p0Request {
GenerateSeedance2p0Request {
prompt: String::new(),
aspect_ratio: None,
output_resolution,
batch_count,
duration_seconds,
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,
}
}
fn r480(dur: u8) -> GenerateSeedance2p0Request {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None)
}
fn r720(dur: u8) -> GenerateSeedance2p0Request {
build_request(dur, None, None)
}
fn r1080(dur: u8) -> GenerateSeedance2p0Request {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
}
// ── 480p credits (15 credits/sec) ──
mod credits_480p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r480(3).estimate_credits(), 45);
assert_eq!(r480(4).estimate_credits(), 60);
assert_eq!(r480(5).estimate_credits(), 75);
assert_eq!(r480(6).estimate_credits(), 90);
assert_eq!(r480(7).estimate_credits(), 105);
assert_eq!(r480(8).estimate_credits(), 120);
assert_eq!(r480(9).estimate_credits(), 135);
assert_eq!(r480(10).estimate_credits(), 150);
assert_eq!(r480(11).estimate_credits(), 165);
assert_eq!(r480(12).estimate_credits(), 180);
assert_eq!(r480(13).estimate_credits(), 195);
assert_eq!(r480(14).estimate_credits(), 210);
assert_eq!(r480(15).estimate_credits(), 225);
}
}
// ── 720p credits (40 credits/sec) ──
mod credits_720p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r720(3).estimate_credits(), 120);
assert_eq!(r720(4).estimate_credits(), 160);
assert_eq!(r720(5).estimate_credits(), 200);
assert_eq!(r720(6).estimate_credits(), 240);
assert_eq!(r720(7).estimate_credits(), 280);
assert_eq!(r720(8).estimate_credits(), 320);
assert_eq!(r720(9).estimate_credits(), 360);
assert_eq!(r720(10).estimate_credits(), 400);
assert_eq!(r720(11).estimate_credits(), 440);
assert_eq!(r720(12).estimate_credits(), 480);
assert_eq!(r720(13).estimate_credits(), 520);
assert_eq!(r720(14).estimate_credits(), 560);
assert_eq!(r720(15).estimate_credits(), 600);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).estimate_credits();
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).estimate_credits();
assert_eq!(default, explicit);
}
}
// ── 1080p credits (90 credits/sec) ──
mod credits_1080p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r1080(3).estimate_credits(), 270);
assert_eq!(r1080(4).estimate_credits(), 360);
assert_eq!(r1080(5).estimate_credits(), 450);
assert_eq!(r1080(6).estimate_credits(), 540);
assert_eq!(r1080(7).estimate_credits(), 630);
assert_eq!(r1080(8).estimate_credits(), 720);
assert_eq!(r1080(9).estimate_credits(), 810);
assert_eq!(r1080(10).estimate_credits(), 900);
assert_eq!(r1080(11).estimate_credits(), 990);
assert_eq!(r1080(12).estimate_credits(), 1080);
assert_eq!(r1080(13).estimate_credits(), 1170);
assert_eq!(r1080(14).estimate_credits(), 1260);
assert_eq!(r1080(15).estimate_credits(), 1350);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_1_is_base() {
let base = r720(5).estimate_credits();
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).estimate_credits();
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).estimate_credits();
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).estimate_credits();
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
let base = r1080(5).estimate_credits();
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).estimate_credits();
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
}
// ── Relative pricing ──
mod relative_tests {
use super::*;
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).estimate_credits();
let c10 = r720(10).estimate_credits();
let c15 = r720(15).estimate_credits();
assert!(c3 < c10);
assert!(c10 < c15);
}
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).estimate_credits();
let c720 = build_request(dur, None, None).estimate_credits();
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).estimate_credits();
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
}
}
}
// ── USD cents ──
mod usd_cents_tests {
use super::*;
#[test]
fn credits_per_dollar_is_193() {
assert_eq!(GenerateSeedance2p0Request::credits_per_dollar(), 193.0);
}
#[test]
fn usd_cents_720p_5s() {
// 200 credits / 193 * 100 = 103.63 → 104¢
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 104);
}
#[test]
fn usd_cents_480p_5s() {
// 75 credits / 193 * 100 = 38.86 → 39¢
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 39);
}
#[test]
fn usd_cents_1080p_5s() {
// 450 credits / 193 * 100 = 233.16 → 233¢
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 233);
}
#[test]
fn usd_cents_720p_15s() {
// 600 credits / 193 * 100 = 310.88 → 311¢
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 311);
}
#[test]
fn usd_cents_1080p_15s() {
// 1350 credits / 193 * 100 = 699.48 → 699¢
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 699);
}
#[test]
fn batch_multiplies_usd_cents() {
let base = r720(5).estimate_cost_in_usd_cents();
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_cost_in_usd_cents();
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
"batch 2 ({}) should be ~2× base ({})", batch2, base);
}
}
// ── Aspect ratio doesn't affect cost ──
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).estimate_credits();
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
KinoviSeedance2p0AspectRatio::Portrait9x16,
KinoviSeedance2p0AspectRatio::Square1x1,
KinoviSeedance2p0AspectRatio::Standard4x3,
KinoviSeedance2p0AspectRatio::Portrait3x4,
];
for ar in &ratios {
let req = GenerateSeedance2p0Request {
prompt: String::new(),
aspect_ratio: Some(*ar),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
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,
};
assert_eq!(
req.estimate_credits(), baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
}
}
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te";
const MOCHI_ID: &str = "char_1775177718294_g2pitx";
mod text_to_video {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_default() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
aspect_ratio: None,
output_resolution: None,
batch_count: None,
duration_seconds: 5,
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,
},
}).await?;
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_1080p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "A dragon soaring over a medieval castle at sunset".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p0OutputResolution::TenEightyP),
batch_count: None,
duration_seconds: 5,
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,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod keyframe {
use super::*;
#[tokio::test]
#[ignore]
async fn test_keyframe_start_frame() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "The corgi dog watches the lake as the sun sets.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: Some(start_frame_url),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_start_and_end_frame() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
let end_frame_url = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "The dog walks from the lake toward the camera.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: Some(start_frame_url),
end_frame_url: Some(end_frame_url),
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod image_reference {
use super::*;
#[tokio::test]
#[ignore]
async fn test_image_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
let img2 = upload_test_image(&session, test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL).await?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "The dog in @1 runs through the scenery in @2.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![img1, img2]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod video_reference {
use super::*;
#[tokio::test]
#[ignore]
async fn test_video_reference() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "Change @video1 to a nighttime scene with moonlight.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod character_reference {
use super::*;
#[tokio::test]
#[ignore]
async fn test_single_character() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "@Steampunk Clown is juggling flaming torches in a circus tent.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
use_face_blur_hack: None,
},
}).await?;
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_two_characters() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
},
}).await?;
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
async fn upload_test_image(session: &Seedance2ProSession, image_url: &str) -> AnyhowResult<String> {
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
image_url,
).await?;
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "jpg".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: image_bytes,
host_override: None,
}).await?;
Ok(upload_result.public_url)
}
}
@@ -0,0 +1,678 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
WorkflowRunTaskRequest,
};
// ── Args ──
pub struct GenerateSeedance2p0FastArgs<'a> {
pub request: GenerateSeedance2p0FastRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateSeedance2p0FastRequest {
pub prompt: String,
pub aspect_ratio: Option<KinoviSeedance2p0FastAspectRatio>,
pub output_resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0FastBatchCount>,
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 character_ids: Option<Vec<String>>,
pub use_face_blur_hack: Option<bool>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0FastAspectRatio {
Landscape16x9,
Portrait9x16,
Square1x1,
Standard4x3,
Portrait3x4,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0FastOutputResolution {
FourEightyP,
SevenTwentyP,
}
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0FastBatchCount {
One,
Two,
Four,
}
// ── Pricing ──
//
// Seedance 2.0 Fast credit pricing:
//
// | Resolution | Credits/sec |
// |------------|-------------|
// | 480p | 14 |
// | 720p | 28 |
//
// 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).
impl GenerateSeedance2p0FastRequest {
/// Estimate the credit cost for this generation request.
pub fn estimate_credits(&self) -> u32 {
let credits_per_second: u32 = match self.output_resolution {
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 14,
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 28,
};
let per_video = u32::from(self.duration_seconds) * credits_per_second;
let batch_multiplier: u32 = match self.batch_count {
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
};
per_video * batch_multiplier
}
/// Credits per dollar for billing conversion.
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
fn credits_per_dollar() -> f64 {
193.0
}
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let credits = self.estimate_credits() as f64;
let cost = credits / Self::credits_per_dollar() * 100.0;
cost.round() as u64
}
}
// ── Response ──
pub struct GenerateSeedance2p0FastResponse {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_seedance_2p0_fast(
args: GenerateSeedance2p0FastArgs<'_>,
) -> Result<GenerateSeedance2p0FastResponse, Seedance2ProError> {
let req = args.request;
let raw_request = WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2Fast,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
end_frame_url: req.end_frame_url,
reference_image_urls: req.reference_image_urls,
reference_video_urls: req.reference_video_urls,
reference_audio_urls: req.reference_audio_urls,
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
request: raw_request,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSeedance2p0FastResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
task_ids: raw_response.task_ids,
order_ids: raw_response.order_ids,
})
}
// ── Mapping helpers ──
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0FastAspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0FastAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0FastAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
Some(KinoviSeedance2p0FastAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
None => KinoviAspectRatioRaw::Landscape16x9,
}
}
fn map_output_resolution(res: KinoviSeedance2p0FastOutputResolution) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0FastOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0FastOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p0FastBatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
mod pricing_tests {
use super::*;
fn build_request(
duration_seconds: u8,
output_resolution: Option<KinoviSeedance2p0FastOutputResolution>,
batch_count: Option<KinoviSeedance2p0FastBatchCount>,
) -> GenerateSeedance2p0FastRequest {
GenerateSeedance2p0FastRequest {
prompt: String::new(),
aspect_ratio: None,
output_resolution,
batch_count,
duration_seconds,
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,
}
}
fn r480(dur: u8) -> GenerateSeedance2p0FastRequest {
build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None)
}
fn r720(dur: u8) -> GenerateSeedance2p0FastRequest {
build_request(dur, None, None)
}
// ── 480p credits (14 credits/sec) ──
mod credits_480p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r480(3).estimate_credits(), 42);
assert_eq!(r480(4).estimate_credits(), 56);
assert_eq!(r480(5).estimate_credits(), 70);
assert_eq!(r480(6).estimate_credits(), 84);
assert_eq!(r480(7).estimate_credits(), 98);
assert_eq!(r480(8).estimate_credits(), 112);
assert_eq!(r480(9).estimate_credits(), 126);
assert_eq!(r480(10).estimate_credits(), 140);
assert_eq!(r480(11).estimate_credits(), 154);
assert_eq!(r480(12).estimate_credits(), 168);
assert_eq!(r480(13).estimate_credits(), 182);
assert_eq!(r480(14).estimate_credits(), 196);
assert_eq!(r480(15).estimate_credits(), 210);
}
}
// ── 720p credits (28 credits/sec) ──
mod credits_720p {
use super::*;
#[test]
fn every_duration() {
assert_eq!(r720(3).estimate_credits(), 84);
assert_eq!(r720(4).estimate_credits(), 112);
assert_eq!(r720(5).estimate_credits(), 140);
assert_eq!(r720(6).estimate_credits(), 168);
assert_eq!(r720(7).estimate_credits(), 196);
assert_eq!(r720(8).estimate_credits(), 224);
assert_eq!(r720(9).estimate_credits(), 252);
assert_eq!(r720(10).estimate_credits(), 280);
assert_eq!(r720(11).estimate_credits(), 308);
assert_eq!(r720(12).estimate_credits(), 336);
assert_eq!(r720(13).estimate_credits(), 364);
assert_eq!(r720(14).estimate_credits(), 392);
assert_eq!(r720(15).estimate_credits(), 420);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).estimate_credits();
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).estimate_credits();
assert_eq!(default, explicit);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_1_is_base() {
let base = r720(5).estimate_credits();
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).estimate_credits();
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).estimate_credits();
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_credits();
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).estimate_credits();
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).estimate_credits();
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).estimate_credits();
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_credits();
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).estimate_credits();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
}
// ── Relative pricing ──
mod relative_tests {
use super::*;
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).estimate_credits();
let c10 = r720(10).estimate_credits();
let c15 = r720(15).estimate_credits();
assert!(c3 < c10);
assert!(c10 < c15);
}
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).estimate_credits();
let c720 = build_request(dur, None, None).estimate_credits();
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
}
}
}
// ── USD cents ──
mod usd_cents_tests {
use super::*;
#[test]
fn credits_per_dollar_is_193() {
assert_eq!(GenerateSeedance2p0FastRequest::credits_per_dollar(), 193.0);
}
#[test]
fn usd_cents_720p_5s() {
// 140 credits / 193 * 100 = 72.54 → 73¢
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 73);
}
#[test]
fn usd_cents_480p_5s() {
// 70 credits / 193 * 100 = 36.27 → 36¢
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 36);
}
#[test]
fn usd_cents_720p_15s() {
// 420 credits / 193 * 100 = 217.62 → 218¢
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 218);
}
#[test]
fn usd_cents_480p_15s() {
// 210 credits / 193 * 100 = 108.81 → 109¢
assert_eq!(r480(15).estimate_cost_in_usd_cents(), 109);
}
#[test]
fn batch_multiplies_usd_cents() {
let base = r720(5).estimate_cost_in_usd_cents();
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_cost_in_usd_cents();
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
"batch 2 ({}) should be ~2× base ({})", batch2, base);
}
}
// ── Aspect ratio doesn't affect cost ──
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).estimate_credits();
let ratios = [
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
KinoviSeedance2p0FastAspectRatio::Portrait9x16,
KinoviSeedance2p0FastAspectRatio::Square1x1,
KinoviSeedance2p0FastAspectRatio::Standard4x3,
KinoviSeedance2p0FastAspectRatio::Portrait3x4,
];
for ar in &ratios {
let req = GenerateSeedance2p0FastRequest {
prompt: String::new(),
aspect_ratio: Some(*ar),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
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,
};
assert_eq!(
req.estimate_credits(), baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
}
}
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te";
const MOCHI_ID: &str = "char_1775177718294_g2pitx";
mod text_to_video {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_default() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
aspect_ratio: None,
output_resolution: None,
batch_count: None,
duration_seconds: 5,
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,
},
}).await?;
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_480p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "A golden retriever running through a field of sunflowers".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
batch_count: None,
duration_seconds: 5,
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,
},
}).await?;
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod keyframe {
use super::*;
#[tokio::test]
#[ignore]
async fn test_keyframe_start_frame() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "A corgi dog runs along the lake shore, splashing water.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: Some(start_frame_url),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod image_reference {
use super::*;
#[tokio::test]
#[ignore]
async fn test_image_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let img1 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?;
let img2 = upload_test_image(&session, test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL).await?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "The dog in @1 runs through the scenery in @2. Golden hour.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![img1, img2]),
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod video_reference {
use super::*;
#[tokio::test]
#[ignore]
async fn test_video_reference() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "Change @video1 to night time.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod character_reference {
use super::*;
#[tokio::test]
#[ignore]
async fn test_single_character() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "@Mochi the shiba inu is eating a cheese pizza on the table.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![MOCHI_ID.to_string()]),
use_face_blur_hack: None,
},
}).await?;
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_two_characters() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
},
}).await?;
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
async fn upload_test_image(session: &Seedance2ProSession, image_url: &str) -> AnyhowResult<String> {
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
image_url,
).await?;
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "jpg".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: image_bytes,
host_override: None,
}).await?;
Ok(upload_result.public_url)
}
}
@@ -0,0 +1,3 @@
pub mod generate_happy_horse_1p0;
pub mod generate_seedance_2p0;
pub mod generate_seedance_2p0_fast;
@@ -4,5 +4,6 @@ pub (crate) mod test_utils;
// User API
pub mod creds;
pub mod error;
pub mod generate;
pub mod requests;
pub mod utils;
@@ -13,6 +13,13 @@ use wreq_util::Emulation;
// --- Request args ---
/// Wrapper that bundles a [`KinoviGenerateVideoRequest`] with session and host info.
pub struct GenerateVideoArgs<'a> {
pub request: KinoviGenerateVideoRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
/// Video generation parameters (no session/host info).
#[derive(Clone)]
pub struct KinoviGenerateVideoRequest {
@@ -64,13 +71,6 @@ pub struct KinoviGenerateVideoRequest {
pub use_face_blur_hack: Option<bool>,
}
/// Wrapper that bundles a [`KinoviGenerateVideoRequest`] with session and host info.
pub struct GenerateVideoArgs<'a> {
pub request: KinoviGenerateVideoRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
impl std::fmt::Debug for KinoviGenerateVideoRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenerateVideoRequest")
@@ -281,8 +281,9 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty());
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio;
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
@@ -352,8 +353,6 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Seedance2pro request : {:?}", request_body);
println!("\n\nSeedance2pro request : \n\n {:?}\n\n", request_body);
let cookie = args.session.cookies.as_str();
let client = Client::builder()
@@ -1,5 +1,6 @@
pub mod generate_character;
pub mod generate_video;
pub mod workflow_run_task;
pub mod get_user_auth_details;
pub mod kinovi_host;
pub mod poll_characters;
@@ -0,0 +1,2 @@
pub mod workflow_run_task;
mod request_types;
@@ -0,0 +1,68 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize, Debug)]
pub (super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize, Debug)]
pub (super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize, Debug)]
pub (super) struct BatchRequestJson {
#[serde(rename = "businessType")]
pub business_type: &'static str,
#[serde(rename = "apiParams")]
pub api_params: ApiParams,
}
#[derive(Serialize, Debug)]
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: String,
#[serde(rename = "contentMode")]
pub content_mode: &'static str,
pub model: &'static str,
pub duration: String,
pub mode: &'static str,
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
pub output_resolution: Option<&'static str>,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
pub face_blur_mode: Option<&'static str>,
#[serde(rename = "characterIds", skip_serializing_if = "Option::is_none")]
pub character_ids: Option<Vec<String>>,
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
#[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")]
pub audio_urls: Option<Vec<String>>,
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
pub batch_count: Option<u8>,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseData {
pub json: TaskResponseJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub (super) struct TaskResponseJson {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
pub violation_warning: bool,
}
@@ -1,2 +1,3 @@
pub mod aspect_ratio;
pub mod resolution;
pub mod resolution;
pub mod quality;
@@ -0,0 +1,21 @@
use serde_derive::{Deserialize, Serialize};
use artcraft_router::api::common_quality::CommonQuality as RouterCommonQuality;
/// Just cargo culting this. might not be necessary for standalone types anymore
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonQuality {
High,
Medium,
Low,
}
impl CommonQuality {
pub fn to_artcraft_router_type(&self) -> RouterCommonQuality {
match self {
CommonQuality::High => RouterCommonQuality::High,
CommonQuality::Medium => RouterCommonQuality::Medium,
CommonQuality::Low => RouterCommonQuality::Low,
}
}
}
@@ -0,0 +1 @@
pub mod common_quality;
@@ -17,7 +17,7 @@ pub async fn estimate_image_cost_command(
request: EstimateImageCostRequest,
app_env_configs: State<'_, AppEnvConfigs>,
) -> ResponseOrError<EstimateImageCostResponse, EstimateImageCostError> {
debug!("estimate_image_cost_command called");
debug!("estimate_image_cost_command called: {:?}", request);
let result = estimate_image_cost(
&app_env_configs.storyteller_host,
@@ -8,7 +8,7 @@ use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{
EstimateVideoCostResponse,
};
use artcraft_client::endpoints::generate::cost_estimate::video::estimate_video_cost::estimate_video_cost;
use log::debug;
use log::{debug, info};
use tauri::State;
impl SerializeMarker for EstimateVideoCostResponse {}
@@ -18,7 +18,7 @@ pub async fn estimate_video_cost_command(
request: EstimateVideoCostRequest,
app_env_configs: State<'_, AppEnvConfigs>,
) -> ResponseOrError<EstimateVideoCostResponse, EstimateVideoCostError> {
debug!("estimate_video_cost_command called");
debug!("estimate_video_cost_command called: {:?}", request);
let result = estimate_video_cost(
&app_env_configs.storyteller_host,
@@ -235,6 +235,7 @@ impl From<ArtcraftRouterError> for GenerateError {
ArtcraftRouterError::Provider(ProviderError::Muapi(_)) => Self::ArtcraftRouterNotYetSupportedProvider("muapi"),
ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(_)) => Self::ArtcraftRouterNotYetSupportedProvider("seedance2pro"),
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(message) => Self::ArtcraftRouterNotYetSupportedProvider("unsupported model for new router API"),
}
}
}
@@ -1,4 +1,5 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::quality::common_quality::CommonQuality;
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::image_edit::artcraft::handle_image_edit_artcraft::handle_image_edit_artcraft;
@@ -15,6 +16,8 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::provider_priority::ProviderPriorityStore;
@@ -24,6 +27,9 @@ use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
@@ -36,13 +42,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use std::time::Duration;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
@@ -134,6 +135,10 @@ pub struct EnqueueEditImageCommand {
/// Image resolution
pub image_resolution: Option<EditImageResolution>,
/// Quality.
/// Tends to be used by OpenAI models.
pub quality: Option<CommonQuality>,
/// OPTIONAL.
/// Only for image angle edit models.
pub horizontal_angle: Option<f64>,
@@ -1,3 +1,4 @@
use crate::core::api_adapters::quality::common_quality::CommonQuality;
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
@@ -14,6 +15,8 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::provider_priority::ProviderPriorityStore;
@@ -23,6 +26,9 @@ use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowError;
@@ -34,13 +40,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use std::time::Duration;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
@@ -95,6 +96,10 @@ pub struct EnqueueInpaintImageCommand {
/// Number of images to generate.
pub image_count: Option<u32>,
/// Quality.
/// Tends to be used by OpenAI models.
pub quality: Option<CommonQuality>,
/// If true, force the dimensions of the source image and mask image to match.
pub require_matching_dimensions: Option<bool>,
@@ -73,6 +73,7 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::NanoBananaPro) => Some(TaskModelType::NanoBananaPro),
Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1),
Some(GenerationModel::GptImage1p5) => Some(TaskModelType::GptImage1p5),
Some(GenerationModel::GptImage2) => Some(TaskModelType::GptImage2),
Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4),
Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5),
Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite),
@@ -37,6 +37,7 @@ pub async fn handle_text_to_image_artcraft(
TextToImageModel::FluxPro11Ultra => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::FluxPro11Ultra, GenerationModel::FluxPro11Ultra).await,
TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
TextToImageModel::GptImage2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage2, GenerationModel::GptImage2).await,
TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => {
handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await
},
@@ -11,6 +11,7 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterCommonAspectRatio;
use artcraft_router::api::common_image_model::CommonImageModel;
use artcraft_router::api::common_resolution::CommonResolution as RouterCommonResolution;
use artcraft_router::api::common_quality::CommonQuality as RouterCommonQuality;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
@@ -42,6 +43,7 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
let aspect_ratio = get_aspect_ratio_t2i(request);
let resolution = get_resolution_t2i(request);
let quality = get_quality_t2i(request);
let router_request = GenerateImageRequest {
model,
@@ -50,7 +52,7 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
image_inputs,
resolution,
aspect_ratio,
quality: None, // TODO: Add quality
quality,
image_batch_count: request.number_images.map(|n| n as u16),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
generation_mode_mismatch_strategy: None,
@@ -117,6 +119,10 @@ fn get_resolution_t2i(request: &EnqueueTextToImageRequest) -> Option<RouterCommo
None
}
fn get_quality_t2i(request: &EnqueueTextToImageRequest) -> Option<RouterCommonQuality> {
request.quality.map(|quality| quality.to_artcraft_router_type())
}
fn convert_desktop_aspect_ratio(ar: CommonAspectRatio2) -> RouterCommonAspectRatio {
match ar {
CommonAspectRatio2::Auto => RouterCommonAspectRatio::Auto,
@@ -1,4 +1,5 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::quality::common_quality::CommonQuality;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
@@ -59,6 +60,8 @@ pub enum TextToImageModel {
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "gemini_25_flash")]
Gemini25Flash,
#[serde(rename = "nano_banana")]
@@ -99,6 +102,10 @@ pub struct EnqueueTextToImageRequest {
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
pub common_resolution: Option<CommonResolution>,
/// Quality.
/// Tends to be used by OpenAI models.
pub quality: Option<CommonQuality>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
@@ -11,6 +11,7 @@ pub fn text_to_image_model_to_model_type(model: TextToImageModel) -> CommonModel
TextToImageModel::Recraft3 => CommonModelType::Recraft3,
TextToImageModel::GptImage1 => CommonModelType::GptImage1,
TextToImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
TextToImageModel::GptImage2 => CommonModelType::GptImage2,
TextToImageModel::Gemini25Flash => CommonModelType::NanoBanana,
TextToImageModel::NanoBanana => CommonModelType::NanoBanana,
TextToImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
@@ -33,6 +33,8 @@ pub enum GenerationModel {
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "grok_image")]
GrokImage,
#[serde(rename = "gemini_25_flash")]
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.28.0";
pub const ARTCRAFT_VERSION: &str = "0.30.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.28.0",
"version": "0.30.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.28.0",
"version": "0.30.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -111,7 +111,8 @@ pub async fn list_media_files_by_batch_token(args: ListMediaFileByBatchArgs<'_>)
let query = query.build_query_as::<MediaFileListItemInternal>();
let results = query.fetch_all(args.mysql_pool).await?;
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
let results = results.into_iter()
.map(|record| {
MediaFileListItem {
@@ -128,7 +128,8 @@ pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> An
let query = query.build_query_as::<MediaFileListItemInternal>();
let results = query.fetch_all(args.mysql_pool).await?;
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
let results = results.into_iter()
.map(|record| {
MediaFileListItem {
@@ -36,6 +36,10 @@ pub enum StaffAuditAction {
/// Staff sent a manual alert via the pager system.
#[serde(rename = "send_alert")]
SendAlert,
/// Staff edited a user's feature flags.
#[serde(rename = "edit_user_feature_flags")]
EditUserFeatureFlags,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
@@ -51,6 +55,7 @@ impl StaffAuditAction {
Self::UnbanUser => "unban_user",
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
Self::SendAlert => "send_alert",
Self::EditUserFeatureFlags => "edit_user_feature_flags",
}
}
@@ -62,6 +67,7 @@ impl StaffAuditAction {
"unban_user" => Ok(Self::UnbanUser),
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
"send_alert" => Ok(Self::SendAlert),
"edit_user_feature_flags" => Ok(Self::EditUserFeatureFlags),
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
}
}
@@ -74,6 +80,7 @@ impl StaffAuditAction {
Self::UnbanUser,
Self::AddWalletBankedBalance,
Self::SendAlert,
Self::EditUserFeatureFlags,
])
}
}
@@ -94,6 +101,7 @@ mod tests {
assert_serialization(StaffAuditAction::UnbanUser, "unban_user");
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
assert_serialization(StaffAuditAction::EditUserFeatureFlags, "edit_user_feature_flags");
}
#[test]
@@ -104,6 +112,7 @@ mod tests {
assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user");
assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance");
assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert");
assert_eq!(StaffAuditAction::EditUserFeatureFlags.to_str(), "edit_user_feature_flags");
}
#[test]
@@ -114,12 +123,13 @@ mod tests {
assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser);
assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance);
assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert);
assert_eq!(StaffAuditAction::from_str("edit_user_feature_flags").unwrap(), StaffAuditAction::EditUserFeatureFlags);
assert!(StaffAuditAction::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 6;
const EXPECTED_COUNT: usize = 7;
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
}
}
@@ -32,6 +32,14 @@ pub enum UserFeatureFlag {
/// Access to the faster Seedance 2.0 whitelist
#[serde(rename = "sd_wl")]
SeedanceWhitelist,
/// Access to Happy Horse
#[serde(rename = "hh")]
HappyHorse,
/// Access to Happy Horse (with a rate limit)
#[serde(rename = "hh_rl")]
HappyHorseRateLimit,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -48,6 +56,8 @@ impl UserFeatureFlag {
Self::Upload3d => "upload_3d",
Self::VideoStyleTransfer => "video_style_transfer",
Self::SeedanceWhitelist => "sd_wl",
Self::HappyHorse => "hh",
Self::HappyHorseRateLimit => "hh_rl",
}
}
@@ -58,10 +68,36 @@ impl UserFeatureFlag {
"upload_3d" => Ok(Self::Upload3d),
"video_style_transfer" => Ok(Self::VideoStyleTransfer),
"sd_wl" => Ok(Self::SeedanceWhitelist),
"hh" => Ok(Self::HappyHorse),
"hh_rl" => Ok(Self::HappyHorseRateLimit),
_ => Err(format!("invalid value: {:?}", value)),
}
}
pub fn name(&self) -> &'static str {
match self {
Self::ExploreMedia => "Explore Media",
Self::Studio => "Storyteller Studio",
Self::Upload3d => "Upload 3D",
Self::VideoStyleTransfer => "Video Style Transfer",
Self::SeedanceWhitelist => "DO NOT USE: Seedance Whitelist (legacy)",
Self::HappyHorse => "Happy Horse (no Rate Limit)",
Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::ExploreMedia => "Grants a user the ability to list media on FakeYou (admin permission)",
Self::Studio => "Access to Storyteller Studio product",
Self::Upload3d => "Whether users are allowed to upload 3D models",
Self::VideoStyleTransfer => "Access to video style transfer on Storyteller",
Self::SeedanceWhitelist => "DO NOT USE THIS. IT WILL BREAK ACCOUNTS.",
Self::HappyHorse => "Access to Happy Horse (without a rate limit - dangerous)",
Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)",
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
@@ -71,6 +107,8 @@ impl UserFeatureFlag {
Self::Upload3d,
Self::VideoStyleTransfer,
Self::SeedanceWhitelist,
Self::HappyHorse,
Self::HappyHorseRateLimit,
])
}
}
@@ -90,6 +128,8 @@ mod tests {
assert_serialization(UserFeatureFlag::Upload3d, "upload_3d");
assert_serialization(UserFeatureFlag::VideoStyleTransfer, "video_style_transfer");
assert_serialization(UserFeatureFlag::SeedanceWhitelist, "sd_wl");
assert_serialization(UserFeatureFlag::HappyHorse, "hh");
assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl");
}
#[test]
@@ -99,6 +139,8 @@ mod tests {
assert_eq!(UserFeatureFlag::Upload3d.to_str(), "upload_3d");
assert_eq!(UserFeatureFlag::VideoStyleTransfer.to_str(), "video_style_transfer");
assert_eq!(UserFeatureFlag::SeedanceWhitelist.to_str(), "sd_wl");
assert_eq!(UserFeatureFlag::HappyHorse.to_str(), "hh");
assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl");
}
#[test]
@@ -108,18 +150,22 @@ mod tests {
assert_eq!(UserFeatureFlag::from_str("upload_3d").unwrap(), UserFeatureFlag::Upload3d);
assert_eq!(UserFeatureFlag::from_str("video_style_transfer").unwrap(), UserFeatureFlag::VideoStyleTransfer);
assert_eq!(UserFeatureFlag::from_str("sd_wl").unwrap(), UserFeatureFlag::SeedanceWhitelist);
assert_eq!(UserFeatureFlag::from_str("hh").unwrap(), UserFeatureFlag::HappyHorse);
assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit);
assert!(UserFeatureFlag::from_str("foo").is_err());
}
#[test]
fn all_variants() {
let mut variants = UserFeatureFlag::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.len(), 7);
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::VideoStyleTransfer));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::SeedanceWhitelist));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorse));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit));
assert_eq!(variants.pop_first(), None);
}
}
@@ -118,6 +118,8 @@ pub enum CommonModelType {
Kling3p0Standard,
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_1p0_pro")]
@@ -209,6 +211,7 @@ impl CommonModelType {
Self::Kling2p6Pro => "kling_2p6_pro",
Self::Kling3p0Standard => "kling_3p0_standard",
Self::Kling3p0Pro => "kling_3p0_pro",
Self::HappyHorse1p0 => "happy_horse_1p0",
Self::Seedance10Lite => "seedance_1p0_lite",
Self::Seedance10Pro => "seedance_1p0_pro",
Self::Seedance1p5Pro => "seedance_1p5_pro",
@@ -277,6 +280,7 @@ impl CommonModelType {
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
@@ -349,6 +353,7 @@ impl CommonModelType {
Self::Kling2p6Pro,
Self::Kling3p0Standard,
Self::Kling3p0Pro,
Self::HappyHorse1p0,
Self::Seedance10Lite,
Self::Seedance10Pro,
Self::Seedance1p5Pro,
@@ -418,6 +423,7 @@ impl CommonModelType {
Self::Kling2p6Pro => CommonModelClass::Video,
Self::Kling3p0Standard => CommonModelClass::Video,
Self::Kling3p0Pro => CommonModelClass::Video,
Self::HappyHorse1p0 => CommonModelClass::Video,
Self::Seedance10Lite => CommonModelClass::Video,
Self::Seedance10Pro => CommonModelClass::Video,
Self::Seedance1p5Pro => CommonModelClass::Video,
@@ -496,6 +502,7 @@ mod tests {
assert_serialization(CommonModelType::Kling2p6Pro, "kling_2p6_pro");
assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard");
assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro");
assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0");
assert_serialization(CommonModelType::Seedance10Lite, "seedance_1p0_lite");
assert_serialization(CommonModelType::Seedance10Pro, "seedance_1p0_pro");
assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro");
@@ -561,6 +568,7 @@ mod tests {
assert_eq!(CommonModelType::Kling2p6Pro.to_str(), "kling_2p6_pro");
assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
assert_eq!(CommonModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
assert_eq!(CommonModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
@@ -626,6 +634,7 @@ mod tests {
assert_eq!(CommonModelType::from_str("kling_2p6_pro").unwrap(), CommonModelType::Kling2p6Pro);
assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard);
assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro);
assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0);
assert_eq!(CommonModelType::from_str("seedance_1p0_lite").unwrap(), CommonModelType::Seedance10Lite);
assert_eq!(CommonModelType::from_str("seedance_1p0_pro").unwrap(), CommonModelType::Seedance10Pro);
assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro);
@@ -651,7 +660,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 56);
assert_eq!(variants.len(), 57);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -693,6 +702,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling2p6Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro));
@@ -2,7 +2,7 @@ use utoipa::ToSchema;
/// Video models available for generation.
/// Mirrors artcraft_router::api::common_video_model::CommonVideoModel.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
@@ -29,6 +29,9 @@ pub enum CommonVideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -75,6 +78,7 @@ impl CommonVideoModel {
Self::Kling2p6Pro => CommonModelType::Kling2p6Pro,
Self::Kling3p0Standard => CommonModelType::Kling3p0Standard,
Self::Kling3p0Pro => CommonModelType::Kling3p0Pro,
Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0,
Self::Seedance10Lite => CommonModelType::Seedance10Lite,
Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro,
Self::Seedance2p0 => CommonModelType::Seedance2p0,
@@ -94,6 +98,94 @@ impl CommonVideoModel {
mod tests {
use super::*;
use crate::common::generation::common_model_type::CommonModelType;
use crate::test_helpers::assert_serialization;
#[test]
fn test_serialization() {
assert_serialization(CommonVideoModel::GrokVideo, "grok_video");
assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro");
assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro");
assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master");
assert_serialization(CommonVideoModel::Kling2p5TurboPro, "kling_2p5_turbo_pro");
assert_serialization(CommonVideoModel::Kling2p6Pro, "kling_2p6_pro");
assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard");
assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro");
assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0");
assert_serialization(CommonVideoModel::Seedance10Lite, "seedance_1p0_lite");
assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0");
assert_serialization(CommonVideoModel::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(CommonVideoModel::Sora2, "sora_2");
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
assert_serialization(CommonVideoModel::Veo2, "veo_2");
assert_serialization(CommonVideoModel::Veo3, "veo_3");
assert_serialization(CommonVideoModel::Veo3Fast, "veo_3_fast");
assert_serialization(CommonVideoModel::Veo3p1, "veo_3p1");
assert_serialization(CommonVideoModel::Veo3p1Fast, "veo_3p1_fast");
}
#[test]
fn test_deserialization() {
let cases = [
("grok_video", CommonVideoModel::GrokVideo),
("kling_1p6_pro", CommonVideoModel::Kling16Pro),
("kling_2p1_pro", CommonVideoModel::Kling21Pro),
("kling_2p1_master", CommonVideoModel::Kling21Master),
("kling_2p5_turbo_pro", CommonVideoModel::Kling2p5TurboPro),
("kling_2p6_pro", CommonVideoModel::Kling2p6Pro),
("kling_3p0_standard", CommonVideoModel::Kling3p0Standard),
("kling_3p0_pro", CommonVideoModel::Kling3p0Pro),
("happy_horse_1p0", CommonVideoModel::HappyHorse1p0),
("seedance_1p0_lite", CommonVideoModel::Seedance10Lite),
("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro),
("seedance_2p0", CommonVideoModel::Seedance2p0),
("seedance_2p0_fast", CommonVideoModel::Seedance2p0Fast),
("sora_2", CommonVideoModel::Sora2),
("sora_2_pro", CommonVideoModel::Sora2Pro),
("veo_2", CommonVideoModel::Veo2),
("veo_3", CommonVideoModel::Veo3),
("veo_3_fast", CommonVideoModel::Veo3Fast),
("veo_3p1", CommonVideoModel::Veo3p1),
("veo_3p1_fast", CommonVideoModel::Veo3p1Fast),
];
for (json_str, expected) in cases {
let json = format!("\"{}\"", json_str);
let deserialized: CommonVideoModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e));
assert_eq!(deserialized, expected, "Failed for {:?}", json_str);
}
}
#[test]
fn test_round_trip() {
let all = [
CommonVideoModel::GrokVideo,
CommonVideoModel::Kling16Pro,
CommonVideoModel::Kling21Pro,
CommonVideoModel::Kling21Master,
CommonVideoModel::Kling2p5TurboPro,
CommonVideoModel::Kling2p6Pro,
CommonVideoModel::Kling3p0Standard,
CommonVideoModel::Kling3p0Pro,
CommonVideoModel::HappyHorse1p0,
CommonVideoModel::Seedance10Lite,
CommonVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast,
CommonVideoModel::Sora2,
CommonVideoModel::Sora2Pro,
CommonVideoModel::Veo2,
CommonVideoModel::Veo3,
CommonVideoModel::Veo3Fast,
CommonVideoModel::Veo3p1,
CommonVideoModel::Veo3p1Fast,
];
for variant in all {
let json = serde_json::to_string(&variant).unwrap();
let deserialized: CommonVideoModel = serde_json::from_str(&json).unwrap();
assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant);
}
}
#[test]
fn all_video_models_convert_to_common_model_type() {
@@ -106,6 +198,7 @@ mod tests {
(CommonVideoModel::Kling2p6Pro, CommonModelType::Kling2p6Pro),
(CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard),
(CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro),
(CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0),
(CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite),
(CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro),
(CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0),
@@ -39,6 +39,8 @@ pub enum TaskModelType {
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "seedream_4")]
Seedream4,
#[serde(rename = "seedream_4p5")]
@@ -134,6 +136,7 @@ impl TaskModelType {
Self::NanoBananaPro => "nano_banana_pro",
Self::GptImage1 => "gpt_image_1",
Self::GptImage1p5 => "gpt_image_1p5",
Self::GptImage2 => "gpt_image_2",
Self::Seedream4 => "seedream_4",
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
@@ -187,6 +190,7 @@ impl TaskModelType {
"nano_banana_pro" => Ok(Self::NanoBananaPro),
"gpt_image_1" => Ok(Self::GptImage1),
"gpt_image_1p5" => Ok(Self::GptImage1p5),
"gpt_image_2" => Ok(Self::GptImage2),
"seedream_4" => Ok(Self::Seedream4),
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
@@ -243,6 +247,7 @@ impl TaskModelType {
Self::NanoBananaPro,
Self::GptImage1,
Self::GptImage1p5,
Self::GptImage2,
Self::Seedream4,
Self::Seedream4p5,
Self::Seedream5Lite,
@@ -306,6 +311,7 @@ mod tests {
assert_serialization(TaskModelType::NanoBananaPro, "nano_banana_pro");
assert_serialization(TaskModelType::GptImage1, "gpt_image_1");
assert_serialization(TaskModelType::GptImage1p5, "gpt_image_1p5");
assert_serialization(TaskModelType::GptImage2, "gpt_image_2");
assert_serialization(TaskModelType::Seedream4, "seedream_4");
assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5");
assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite");
@@ -358,6 +364,7 @@ mod tests {
assert_eq!(TaskModelType::NanoBananaPro.to_str(), "nano_banana_pro");
assert_eq!(TaskModelType::GptImage1.to_str(), "gpt_image_1");
assert_eq!(TaskModelType::GptImage1p5.to_str(), "gpt_image_1p5");
assert_eq!(TaskModelType::GptImage2.to_str(), "gpt_image_2");
assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4");
assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite");
@@ -410,6 +417,7 @@ mod tests {
assert_eq!(TaskModelType::from_str("nano_banana_pro").unwrap(), TaskModelType::NanoBananaPro);
assert_eq!(TaskModelType::from_str("gpt_image_1").unwrap(), TaskModelType::GptImage1);
assert_eq!(TaskModelType::from_str("gpt_image_1p5").unwrap(), TaskModelType::GptImage1p5);
assert_eq!(TaskModelType::from_str("gpt_image_2").unwrap(), TaskModelType::GptImage2);
assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4);
assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5);
assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite);
@@ -460,7 +468,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 45);
assert_eq!(variants.len(), 46);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
@@ -475,6 +483,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskModelType::NanoBananaPro));
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1));
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1p5));
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage2));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite));
@@ -37,6 +37,14 @@ pub struct JobDependencies {
/// ancient orders that will never match a pending job.
pub maybe_max_job_age: Option<Duration>,
/// Maximum number of retries when a poll_orders request fails before
/// alerting the pager and aborting the iteration.
pub poll_max_retries: u32,
/// Maximum delay in milliseconds between poll retries. The delay increases
/// with each attempt up to this cap.
pub poll_retry_max_delay_millis: u64,
/// Page an alert when available Kinovi credits fall below this threshold.
pub credits_alert_threshold: u64,
@@ -6,7 +6,7 @@ use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob};
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, TaskStatus};
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, PollOrdersResponse, TaskStatus};
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::process_orders_batch::process_orders_batch;
@@ -131,22 +131,7 @@ async fn website_polling_loop(
total_pages_seen, cursor, total_orders_seen
);
let response = match poll_orders(PollOrdersArgs {
session: &deps.seedance2pro_session,
cursor,
host_override: None,
}).await {
Ok(r) => r,
Err(err) => {
warn!("Error polling Kinovi orders: {:?}", err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi API polling failed",
anyhow::anyhow!("poll_orders failed: {:?}", err),
None,
);
}
};
let response = poll_orders_with_retry(deps, cursor).await?;
let page_orders_count = response.orders.len() as u32;
@@ -232,3 +217,48 @@ async fn website_polling_loop(
})
}
/// Poll orders from Kinovi with retries. On transient failures, waits with
/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`).
/// After exhausting retries, alerts the pager and returns an error.
async fn poll_orders_with_retry(
deps: &JobDependencies,
cursor: Option<u64>,
) -> anyhow::Result<PollOrdersResponse> {
let max_retries = deps.poll_max_retries;
for attempt in 1..=max_retries {
match poll_orders(PollOrdersArgs {
session: &deps.seedance2pro_session,
cursor,
host_override: None,
}).await {
Ok(response) => return Ok(response),
Err(err) => {
warn!(
"Error polling Kinovi orders (attempt {}/{}): {:?}",
attempt, max_retries, err
);
if attempt >= max_retries {
return alert_pager_and_return_err(
&deps.pager,
"Kinovi API polling failed after retries",
anyhow::anyhow!("poll_orders failed after {} attempts: {:?}", attempt, err),
None,
);
}
let delay_millis = (attempt as u64 * 2_000).min(deps.poll_retry_max_delay_millis);
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
}
}
}
alert_pager_and_return_err(
&deps.pager,
"Kinovi API polling failed after max retries",
anyhow::anyhow!("poll_orders failed after {} attempts", max_retries),
None,
)
}
@@ -124,6 +124,18 @@ async fn main() -> AnyhowResult<()> {
info!("Max job age threshold: {} hours", duration.num_hours());
}
let poll_max_retries: u32 = easyenv::get_env_num(
"POLL_MAX_RETRIES",
3,
)?;
let poll_retry_max_delay_millis: u64 = easyenv::get_env_num(
"POLL_RETRY_MAX_DELAY_MILLIS",
10_000,
)?;
info!("Poll max retries: {}, max retry delay: {}ms", poll_max_retries, poll_retry_max_delay_millis);
let credits_alert_threshold: u64 = easyenv::get_env_num(
"CREDITS_ALERT_THRESHOLD",
10_000,
@@ -164,6 +176,8 @@ async fn main() -> AnyhowResult<()> {
poll_interval_millis,
maybe_pages_per_batch,
maybe_max_job_age,
poll_max_retries,
poll_retry_max_delay_millis,
credits_alert_threshold,
application_shutdown: application_shutdown.clone(),
shutdown_notify: shutdown_notify.clone(),
@@ -378,6 +378,10 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
model: CommonImageModel::GptImage1p5,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::GptImage2,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::Seedream4,
overrides: None,
@@ -19,6 +19,33 @@ pub const OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS: Lazy<OmniGenVideoModelsResponse>
fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::HappyHorse1p0,
model_creator: Some(ModelCreator::Alibaba),
full_name: Some("Happy Horse 1.0".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(3),
duration_seconds_max: Some(15),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance1p5Pro,
model_creator: Some(ModelCreator::Bytedance),
@@ -85,7 +85,9 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asse
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::*;
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
@@ -397,7 +399,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::write_engine_asset_media_file_handler,
crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::write_scene_file_media_file_handler,
crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::enqueue_gptsovits_model_download_handler,
crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler,
crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler,
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler,
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::moderator_list_user_feature_flags_handler,
crate::http_server::endpoints::prompts::create_prompt_handler::create_prompt_handler,
crate::http_server::endpoints::prompts::get_prompt_handler::get_prompt_handler,
crate::http_server::endpoints::service::status_alert_handler::status_alert_handler,
@@ -9,6 +9,7 @@ use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{
};
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterAspectRatio;
use artcraft_router::api::common_image_model::CommonImageModel as RouterImageModel;
use artcraft_router::api::common_quality::CommonQuality as RouterQuality;
use artcraft_router::api::common_resolution::CommonResolution as RouterResolution;
use artcraft_router::api::provider::Provider as RouterProvider;
use artcraft_router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
@@ -16,6 +17,7 @@ use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismat
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
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;
@@ -37,6 +39,7 @@ pub async fn estimate_image_cost_handler(
let router_model = map_image_model(request.model)?;
let router_aspect_ratio = request.aspect_ratio.map(map_aspect_ratio);
let router_resolution = request.resolution.map(map_resolution);
let router_quality = request.quality.map(map_quality);
let router_request = GenerateImageRequest {
model: router_model,
@@ -45,7 +48,7 @@ pub async fn estimate_image_cost_handler(
image_inputs: None, // TODO: Only some models charge for this - we'll need to add later.
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
quality: None,
quality: router_quality,
image_batch_count: request.image_batch_count,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway),
@@ -132,13 +135,13 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::FluxPro11Ultra => RouterImageModel::FluxPro11Ultra,
CommonImageModel::GptImage1 => RouterImageModel::GptImage1,
CommonImageModel::GptImage1p5 => RouterImageModel::GptImage1p5,
CommonImageModel::GptImage2 => RouterImageModel::GptImage2,
CommonImageModel::NanoBanana => RouterImageModel::NanoBanana,
CommonImageModel::NanoBanana2 => RouterImageModel::NanoBanana2,
CommonImageModel::NanoBananaPro => RouterImageModel::NanoBananaPro,
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
CommonImageModel::GptImage2 => RouterImageModel::GptImage2,
};
Ok(router_model)
}
@@ -178,3 +181,11 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
CommonResolution::TenEightyP => RouterResolution::TenEightyP,
}
}
fn map_quality(res: CommonQuality) -> RouterQuality {
match res {
CommonQuality::Low => RouterQuality::Low,
CommonQuality::Medium => RouterQuality::Medium,
CommonQuality::High => RouterQuality::High,
}
}
@@ -141,6 +141,7 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
CommonVideoModel::Veo2 => RouterVideoModel::Veo2,
@@ -1 +1,3 @@
pub mod edit_user_feature_flags_handler;
pub mod moderator_edit_user_feature_flags_handler;
pub mod moderator_list_all_available_user_feature_flags_handler;
pub mod moderator_list_user_feature_flags_handler;
@@ -1,6 +1,7 @@
use std::collections::BTreeSet;
use std::fmt;
use std::iter::FromIterator;
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::error::ResponseError;
@@ -9,20 +10,27 @@ use actix_web::web::{Data, Json, Path};
use actix_web::{HttpRequest, HttpResponse};
use log::warn;
use redis::{Client, Commands};
use sqlx::Acquire;
use utoipa::ToSchema;
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{insert_staff_audit_log, InsertStaffAuditLogArgs};
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_user_feature_flags, SetUserFeatureFlagArgs};
use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token;
use tokens::tokens::users::UserToken;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
/// For the URL PathInfo
#[derive(Deserialize, ToSchema)]
@@ -94,7 +102,7 @@ impl fmt::Display for EditUserFeatureFlagsError {
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/user_feature_flags/{username_or_token}",
path = "/v1/moderation/user_feature_flags/user/{username_or_token}",
responses(
(status = 200, description = "Success", body = SimpleGenericJsonSuccess),
(status = 401, description = "Unauthorized", body = EditUserFeatureFlagsError),
@@ -106,66 +114,47 @@ impl fmt::Display for EditUserFeatureFlagsError {
("request" = EditUserFeatureFlagsRequest, description = "Payload for Request"),
)
)]
pub async fn edit_user_feature_flags_handler(
pub async fn moderator_edit_user_feature_flags_handler(
http_request: HttpRequest,
path: Path<EditUserFeatureFlagPathInfo>,
request: Json<EditUserFeatureFlagsRequest>,
server_state: Data<Arc<ServerState>>,
//redis_ttl_cache: Data<RedisTtlCache>,
redis_pool: Data<r2d2::Pool<Client>>,
) -> Result<HttpResponse, EditUserFeatureFlagsError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => {
return Err(EditUserFeatureFlagsError::Unauthorized);
}
};
if !user_session.can_ban_users {
warn!("user is not allowed to add bans: {:?}", user_session.user_token.as_str());
return Err(EditUserFeatureFlagsError::Unauthorized);
}
let user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
EditUserFeatureFlagsError::Unauthorized
})?;
let username_or_token = path.username_or_token.trim();
let user_token;
if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
user_token = UserToken::new_from_str(username_or_token);
let user_token = if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
UserToken::new_from_str(username_or_token)
} else {
user_token = get_user_token_by_username(&username_or_token, &server_state.mysql_pool)
get_user_token_by_username(username_or_token, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Could not get user token by username: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?
.ok_or_else(|| {
EditUserFeatureFlagsError::ServerError
})?;
}
.ok_or(EditUserFeatureFlagsError::ServerError)?
};
let user_profile = get_user_profile_by_token(&user_token, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Could not get user session by token: {:?}", e);
warn!("Could not get user profile by token: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?
.ok_or_else(|| {
EditUserFeatureFlagsError::ServerError
})?;
.ok_or(EditUserFeatureFlagsError::ServerError)?;
let mut user_feature_flags =
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
match &request.action {
EditUserFeatureFlagsOption::AddFlags { flags } => {
@@ -189,6 +178,7 @@ pub async fn edit_user_feature_flags_handler(
let ip_address = get_request_ip(&http_request);
// Update the user's feature flags.
set_user_feature_flags(SetUserFeatureFlagArgs {
subject_user_token: &user_profile.user_token,
maybe_feature_flags: user_feature_flags.maybe_serialize_string().as_deref(),
@@ -201,16 +191,45 @@ pub async fn edit_user_feature_flags_handler(
EditUserFeatureFlagsError::ServerError
})?;
//if let Ok(mut redis) = redis_ttl_cache.get_connection() {
// // TODO(bt,2024-04-20): This should be coordinated with other code.
// let cache_key = format!("cache:userProfile:{}", user_profile.username);
// let _r = redis.delete_from_cache(&cache_key);
//}
// Insert staff audit log.
let mut mysql_connection = server_state.mysql_pool.acquire()
.await
.map_err(|e| {
warn!("Could not acquire MySQL connection for audit log: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
let mut transaction = mysql_connection.begin()
.await
.map_err(|e| {
warn!("Could not start transaction for audit log: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::EditUserFeatureFlags,
maybe_entity_type: Some(StaffAuditEntityType::User),
maybe_entity_token: Some(user_profile.user_token.as_str()),
staff_user_token: &user_session.user_token,
actor_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert staff audit log: {:?}", err);
EditUserFeatureFlagsError::ServerError
})?;
transaction.commit()
.await
.map_err(|e| {
warn!("Could not commit audit log transaction: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
// Invalidate Redis cache for the user profile.
if let Ok(mut redis) = redis_pool.get() {
// TODO(bt,2024-04-20): This should be coordinated with other code.
let cache_key = format!("cache:userProfile:{}", user_profile.username);
let _r : Result<Option<String>, _> = redis.del(&cache_key);
let _r: Result<Option<String>, _> = redis.del(&cache_key);
}
Ok(simple_json_success())
@@ -0,0 +1,74 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use utoipa::ToSchema;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserFeatureFlagsResponse {
pub success: bool,
pub feature_flags: Vec<FeatureFlagDescriptor>,
}
#[derive(Serialize, ToSchema)]
pub struct FeatureFlagDescriptor {
/// Key the flag is identified by
pub key: String,
/// Full name of the flag
pub full_name: String,
/// Description of the flag
pub description: String,
}
// ── Handler ──
/// List all available user feature flags.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_feature_flags/list",
responses(
(status = 200, description = "Success", body = ModeratorListUserFeatureFlagsResponse),
(status = 401, description = "Unauthorized"),
),
)]
pub async fn moderator_list_all_available_user_feature_flags_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, AdvancedCommonWebError> {
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let feature_flags: Vec<FeatureFlagDescriptor> = UserFeatureFlag::all_variants()
.into_iter()
.map(|flag| FeatureFlagDescriptor {
key: flag.to_str().to_string(),
full_name: flag.name().to_string(),
description: flag.description().to_string(),
})
.collect();
Ok(Json(ModeratorListUserFeatureFlagsResponse {
success: true,
feature_flags,
}))
}
@@ -0,0 +1,109 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use utoipa::ToSchema;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
// ── Request ──
#[derive(Deserialize, ToSchema)]
pub struct ListUserFeatureFlagsPathInfo {
username_or_token: String,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserFeatureFlagsResponse {
pub success: bool,
pub user_token: String,
pub username: String,
pub display_name: String,
pub feature_flags: Vec<String>,
}
// ── Handler ──
/// List the feature flags enabled for a specific user.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_feature_flags/user/{username_or_token}",
responses(
(status = 200, description = "Success", body = ModeratorListUserFeatureFlagsResponse),
(status = 401, description = "Unauthorized"),
(status = 400, description = "Bad input"),
),
params(
("username_or_token" = String, Path, description = "Username or user token"),
)
)]
pub async fn moderator_list_user_feature_flags_handler(
http_request: HttpRequest,
path: Path<ListUserFeatureFlagsPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, AdvancedCommonWebError> {
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let username_or_token = path.username_or_token.trim();
let user_token = if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
UserToken::new_from_str(username_or_token)
} else {
get_user_token_by_username(username_or_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Could not get user token by username: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
AdvancedCommonWebError::NotFound
})?
};
let user_profile = get_user_profile_by_token(&user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Could not get user profile by token: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
AdvancedCommonWebError::NotFound
})?;
let user_feature_flags =
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
let flags: Vec<String> = user_feature_flags.clone_flags()
.iter()
.map(|flag| flag.to_str().to_string())
.collect();
Ok(Json(ModeratorListUserFeatureFlagsResponse {
success: true,
user_token: user_profile.user_token.as_str().to_string(),
username: user_profile.username,
display_name: user_profile.display_name,
feature_flags: flags,
}))
}
@@ -6,10 +6,11 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost
use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse;
use artcraft_router::api::provider::Provider;
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use log::warn;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::omni_gen::generate::video::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::state::server_state::ServerState;
/// Estimate the cost of a video generation.
@@ -29,27 +30,39 @@ pub async fn omni_gen_video_cost_handler(
request: Json<OmniGenVideoCostAndGenerateRequest>,
_server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenVideoCostResponse>, AdvancedCommonWebError> {
let mut generate_request = hydrate_to_router_request(&request)?;
generate_request.provider = Provider::Artcraft;
let mut builder = hydrate_to_router_request(&request)?;
builder.provider = Provider::Artcraft;
let plan = match generate_request.build() {
Ok(plan) => plan,
Err(ArtcraftRouterError::UnsupportedModel(_)) => {
generate_request.provider = Provider::Fal;
generate_request.build()
.map_err(|e| {
warn!("Failed to build Fal cost plan: {}", e);
AdvancedCommonWebError::from_error(e)
})?
}
Err(e) => {
warn!("Failed to build cost plan: {}", e);
return Err(AdvancedCommonWebError::from_error(e));
}
let estimate = if builder.use_new_builder() {
builder.build2()
.map_err(|e| {
warn!("Failed to build2 cost estimate: {}", e);
AdvancedCommonWebError::from_error(e)
})?
.estimate_cost()
.map_err(|e| {
warn!("Failed to estimate cost (v2): {}", e);
AdvancedCommonWebError::from_error(e)
})?
} else {
let plan = match builder.build() {
Ok(plan) => plan,
Err(ArtcraftRouterError::UnsupportedModel(_)) => {
builder.provider = Provider::Fal;
builder.build()
.map_err(|e| {
warn!("Failed to build Fal cost plan: {}", e);
AdvancedCommonWebError::from_error(e)
})?
}
Err(e) => {
warn!("Failed to build cost plan: {}", e);
return Err(AdvancedCommonWebError::from_error(e));
}
};
plan.estimate_costs()
};
let estimate = plan.estimate_costs();
Ok(Json(OmniGenVideoCostResponse {
success: true,
cost_in_credits: estimate.cost_in_credits,
@@ -1,196 +0,0 @@
//! Distillation step for the omni-gen video endpoints.
//!
//! Takes a raw [`OmniGenVideoCostAndGenerateRequest`] plus a (pre-computed)
//! `MediaFileToken -> Url` map and produces a fully self-contained
//! [`DistilledVideoRequest`] holding:
//! - the (private) router request, kept for inspection in tests / debugging
//! - the [`VideoGenerationCostEstimate`] (Artcraft provider, what we bill on)
//! - the [`VideoGenerationPlan`] (execution provider, what we actually execute)
use std::collections::HashMap;
use log::warn;
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
/// Self-contained, owned representation of a fully-distilled omni-gen video
/// request: the router request, the bill-on cost estimate, and the executable
/// plan, all in one place.
pub struct DistilledVideoRequest {
request: GenerateVideoRequestBuilder,
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
pub cost: VideoGenerationCostEstimate,
/// Execution plan as computed by the execution provider — what we hand to the router.
pub plan: VideoGenerationPlan,
/// The provider used for execution (Fal, Seedance2Pro, etc.).
pub execution_provider: Provider,
}
impl DistilledVideoRequest {
/// Borrow the execution plan.
pub fn plan(&self) -> &VideoGenerationPlan {
&self.plan
}
/// Borrow the underlying router request. Useful for tests / debugging.
#[allow(dead_code)]
pub(crate) fn request(&self) -> &GenerateVideoRequestBuilder {
&self.request
}
}
/// Build a [`DistilledVideoRequest`] from a raw API request and a pre-computed
/// `MediaFileToken -> Url` hydration map.
pub fn distill_video_request(
request: &OmniGenVideoCostAndGenerateRequest,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
execution_provider: Provider,
) -> Result<DistilledVideoRequest, AdvancedCommonWebError> {
// 1. Convert the raw API request into a router request.
let initial = hydrate_to_router_request(request)?;
// 2. Cost estimate (Artcraft provider).
// `initial` already has media fields in token form (from hydrate_to_router_request),
// which is exactly what the Artcraft cost builder needs. Just swap the provider.
let cost: VideoGenerationCostEstimate = {
let cost_request = GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
..initial.clone()
};
let cost_plan = cost_request.build().map_err(|e| {
warn!("Failed to build cost plan during video distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?;
cost_plan.estimate_costs()
};
// 3. Resolve media tokens to URLs for the execution request.
let start_frame_url = resolve_single_media_token(
request.start_frame_image_media_token.as_ref(),
media_file_hydration_map,
)?;
let end_frame_url = resolve_single_media_token(
request.end_frame_image_media_token.as_ref(),
media_file_hydration_map,
)?;
let reference_image_urls = resolve_media_token_list(
request.reference_image_media_tokens.as_ref(),
media_file_hydration_map,
)?;
let reference_video_urls = resolve_media_token_list(
request.reference_video_media_tokens.as_ref(),
media_file_hydration_map,
)?;
let reference_audio_urls = resolve_media_token_list(
request.reference_audio_media_tokens.as_ref(),
media_file_hydration_map,
)?;
// 4. Build the execution request with resolved URLs.
let exec_request = GenerateVideoRequestBuilder {
model: initial.model,
provider: execution_provider,
prompt: initial.prompt,
negative_prompt: initial.negative_prompt,
start_frame: start_frame_url.map(ImageRef::Url),
end_frame: end_frame_url.map(ImageRef::Url),
reference_images: reference_image_urls.map(ImageListRef::Urls),
reference_videos: reference_video_urls.map(VideoListRef::Urls),
reference_audio: reference_audio_urls.map(AudioListRef::Urls),
reference_character_tokens: None,
resolution: initial.resolution,
aspect_ratio: initial.aspect_ratio,
duration_seconds: initial.duration_seconds,
video_batch_count: initial.video_batch_count,
generate_audio: initial.generate_audio,
request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy,
idempotency_token: initial.idempotency_token,
};
// 5. Build the execution plan.
let plan = exec_request.build().map_err(|e| {
warn!("Failed to build video generation plan during distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?;
Ok(DistilledVideoRequest {
request: exec_request,
cost,
plan,
execution_provider,
})
}
/// Resolve a single optional media token to its URL string.
fn resolve_single_media_token(
token: Option<&MediaFileToken>,
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<Option<String>, AdvancedCommonWebError> {
let token = match token {
Some(t) => t,
None => return Ok(None),
};
let map = hydration_map.ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
"media token supplied but no hydration map was provided".to_string(),
)
})?;
match map.get(token) {
Some(url) => Ok(Some(url.to_string())),
None => Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Media token not found in hydration map: {:?}",
token
))),
}
}
/// Resolve a list of media tokens to their URL strings.
fn resolve_media_token_list(
tokens: Option<&Vec<MediaFileToken>>,
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
let tokens = match tokens {
Some(tokens) if !tokens.is_empty() => tokens,
_ => return Ok(None),
};
let map = hydration_map.ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
"media tokens supplied but no hydration map was provided".to_string(),
)
})?;
let mut urls: Vec<String> = Vec::with_capacity(tokens.len());
for token in tokens {
match map.get(token) {
Some(url) => urls.push(url.to_string()),
None => {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Media token not found in hydration map: {:?}",
token
)));
}
}
}
Ok(Some(urls))
}
@@ -1,55 +0,0 @@
//! Fal provider execution path.
use log::warn;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use super::super::distill_video_request::DistilledVideoRequest;
use super::execute_generation::GenerationResult;
/// Execute generation via Fal (the existing path).
pub(super) async fn execute_generation_fal(
distilled: &DistilledVideoRequest,
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
) -> Result<GenerationResult, AdvancedCommonWebError> {
let fal_client = RouterFalClient::new(
server_state.fal.api_key.clone(),
server_state.fal.webhook_url.clone(),
);
let router_client = RouterClient::Fal(fal_client);
let generation_response = distilled.plan().generate_video(&router_client)
.await
.map_err(|e| {
warn!("Video generation failed (Fal): {:?}", e);
AdvancedCommonWebError::from_error(e)
})?;
let external_job_id = match &generation_response {
GenerateVideoResponse::Artcraft(p) => p.inference_job_token.as_str().to_string(),
GenerateVideoResponse::Muapi(p) => p.request_id.as_str().to_string(),
GenerateVideoResponse::Seedance2Pro(p) => p.order_id.clone(),
GenerateVideoResponse::Fal(p) => p.request_id.clone().unwrap_or_default(),
};
let generation_mode = if request.start_frame_image_media_token.is_some() {
CommonGenerationMode::Keyframe
} else {
CommonGenerationMode::Text
};
Ok(GenerationResult {
external_job_id,
is_seedance2pro: false,
maybe_seedance_order_ids: None,
generation_mode,
})
}
@@ -1,68 +0,0 @@
//! Main entrypoint for video generation execution.
//!
//! Routes to the appropriate provider (Fal or Seedance2Pro/Kinovi)
//! based on the distilled request's `execution_provider`.
use std::collections::HashMap;
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::api::provider::Provider;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use super::super::distill_video_request::DistilledVideoRequest;
use super::execute_fal::execute_generation_fal;
use super::execute_kinovi::execute_generation_kinovi;
/// Result of a successful generation, regardless of provider.
pub struct GenerationResult {
/// The external job ID used to track the generation (first order_id for Seedance).
pub external_job_id: String,
/// Whether this is a Seedance2Pro generation (changes DB insertion path).
pub is_seedance2pro: bool,
/// For Seedance2Pro batch jobs, the list of all order IDs.
pub maybe_seedance_order_ids: Option<Vec<String>>,
/// The generation mode (Text, Keyframe, Reference).
pub generation_mode: CommonGenerationMode,
}
/// Execute video generation via the appropriate provider.
pub async fn execute_generation(
distilled: &DistilledVideoRequest,
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
kinovi_character_ids: Option<Vec<String>>,
maybe_wallet_ledger_entry_token: Option<&WalletLedgerEntryToken>,
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
) -> Result<GenerationResult, AdvancedCommonWebError> {
match distilled.execution_provider {
Provider::Seedance2Pro => {
execute_generation_kinovi(
distilled,
request,
server_state,
media_file_hydration_map,
kinovi_character_ids,
maybe_wallet_ledger_entry_token,
mysql_connection,
).await
}
_ => {
execute_generation_fal(
distilled,
request,
server_state,
).await
}
}
}
@@ -1,3 +0,0 @@
mod execute_fal;
mod execute_kinovi;
pub mod execute_generation;
@@ -0,0 +1,44 @@
//! Shared wallet billing logic for both video generation pipelines.
use log::info;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
pub struct BillWalletResult {
pub apriori_job_token: InferenceJobToken,
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Generate an apriori job token and bill the user's wallet for the given cost.
///
/// If cost is 0, no wallet deduction is made but the apriori token is still generated.
pub async fn bill_wallet(
user_token: &UserToken,
cost: u64,
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
) -> Result<BillWalletResult, AdvancedCommonWebError> {
let apriori_job_token = InferenceJobToken::generate();
info!("Charging wallet: {} credits", cost);
let maybe_wallet_ledger_entry_token = if cost > 0 {
let deduction_result = attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost,
mysql_connection,
).await?;
Some(deduction_result.ledger_entry_token)
} else {
None
};
Ok(BillWalletResult {
apriori_job_token,
maybe_wallet_ledger_entry_token,
})
}
@@ -0,0 +1,36 @@
//! Build a RouterClient for the given provider from server state.
use artcraft_router::api::provider::Provider;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
pub fn build_router_client(
provider: Provider,
server_state: &ServerState,
) -> Result<RouterClient, AdvancedCommonWebError> {
match provider {
Provider::Seedance2Pro => {
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
);
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
}
Provider::Fal => {
let fal_client = RouterFalClient::new(
server_state.fal.api_key.clone(),
server_state.fal.webhook_url.clone(),
);
Ok(RouterClient::Fal(fal_client))
}
other => {
Err(AdvancedCommonWebError::server_error_with_message(
&format!("Unsupported provider for video generation: {:?}", other),
))
}
}
}
@@ -1,6 +1,7 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioRouter;
use artcraft_router::api::common_resolution::CommonResolution as CommonResolutionRouter;
use artcraft_router::api::common_video_model::CommonVideoModel as CommonVideoModelRouter;
@@ -14,6 +15,54 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
pub fn hydrate_to_router_request(
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<GenerateVideoRequestBuilder, AdvancedCommonWebError> {
let api_model = request.model
.as_ref()
.ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage(
"model is required".to_string(),
))?;
let model = convert_model(api_model)?;
let aspect_ratio = request.aspect_ratio
.as_ref()
.map(convert_aspect_ratio)
.transpose()?;
let resolution = request.resolution
.as_ref()
.map(convert_resolution)
.transpose()?;
Ok(GenerateVideoRequestBuilder {
model,
provider: Provider::Artcraft,
prompt: request.prompt.clone(),
negative_prompt: request.negative_prompt.clone(),
start_frame: request.start_frame_image_media_token.clone()
.map(ImageRef::MediaFileToken),
end_frame: request.end_frame_image_media_token.clone()
.map(ImageRef::MediaFileToken),
reference_images: request.reference_image_media_tokens.clone()
.map(ImageListRef::MediaFileTokens),
reference_videos: request.reference_video_media_tokens.clone()
.map(VideoListRef::MediaFileTokens),
reference_audio: request.reference_audio_media_tokens.clone()
.map(AudioListRef::MediaFileTokens),
reference_character_tokens: request.reference_character_tokens.clone()
.map(CharacterListRef::CharacterTokens),
resolution,
aspect_ratio,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: request.idempotency_token.clone(),
})
}
fn convert_model(
model: &CommonVideoModelEnum,
) -> Result<CommonVideoModelRouter, AdvancedCommonWebError> {
@@ -46,47 +95,3 @@ fn convert_resolution(
)
})
}
pub fn hydrate_to_router_request(
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<GenerateVideoRequestBuilder, AdvancedCommonWebError> {
let api_model = request.model.as_ref()
.ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage(
"model is required".to_string(),
))?;
let model = convert_model(api_model)?;
let aspect_ratio = request.aspect_ratio.as_ref()
.map(convert_aspect_ratio)
.transpose()?;
let resolution = request.resolution.as_ref()
.map(convert_resolution)
.transpose()?;
Ok(GenerateVideoRequestBuilder {
model,
provider: Provider::Artcraft,
prompt: request.prompt.clone(),
negative_prompt: request.negative_prompt.clone(),
start_frame: request.start_frame_image_media_token.clone()
.map(ImageRef::MediaFileToken),
end_frame: request.end_frame_image_media_token.clone()
.map(ImageRef::MediaFileToken),
reference_images: request.reference_image_media_tokens.clone()
.map(ImageListRef::MediaFileTokens),
reference_videos: request.reference_video_media_tokens.clone()
.map(VideoListRef::MediaFileTokens),
reference_audio: request.reference_audio_media_tokens.clone()
.map(AudioListRef::MediaFileTokens),
reference_character_tokens: None,
resolution,
aspect_ratio,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: request.idempotency_token.clone(),
})
}
@@ -0,0 +1,5 @@
pub mod bill_wallet;
pub mod build_router_client;
pub(crate) mod hydrate_router_request;
pub mod pipeline_result;
pub mod resolve_kinovi_character_ids;
@@ -0,0 +1,9 @@
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult;
/// Both pipelines produce this, then the shared suffix handles DB writes.
pub struct PipelineResult {
pub billing: BillWalletResult,
pub response: GenerateVideoResponse,
}
@@ -1,5 +1,7 @@
//! Resolve character tokens to Kinovi character IDs for prompting.
use std::collections::HashMap;
use log::warn;
use sqlx::MySql;
@@ -8,14 +10,14 @@ use tokens::tokens::characters::CharacterToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
/// Resolve character tokens to Kinovi character IDs for prompting.
/// Resolve character tokens to a map of CharacterToken → Kinovi character ID.
///
/// Looks up the characters, filters to active ones with kinovi IDs, and warns about
/// any that are missing or inactive (but doesn't fail the request).
pub async fn resolve_kinovi_character_ids(
maybe_tokens: Option<&[CharacterToken]>,
connection: &mut sqlx::pool::PoolConnection<MySql>,
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
) -> Result<Option<HashMap<CharacterToken, String>>, AdvancedCommonWebError> {
let tokens = match maybe_tokens {
None => return Ok(None),
Some(tokens) if tokens.is_empty() => return Ok(None),
@@ -38,10 +40,12 @@ pub async fn resolve_kinovi_character_ids(
}
}
let ids: Vec<String> = characters.iter()
.filter(|c| c.is_active)
.filter_map(|c| c.kinovi_character_id.clone())
.collect();
let map: HashMap<CharacterToken, String> = characters.iter()
.filter(|c| c.is_active)
.filter_map(|c| {
c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone()))
})
.collect();
if ids.is_empty() { Ok(None) } else { Ok(Some(ids)) }
if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
}
@@ -0,0 +1,53 @@
use log::warn;
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertFalJobArgs<'a, 'tx> {
pub external_job_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
let InsertFalJobArgs {
external_job_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
maybe_external_third_party_id: external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting fal inference job: {:?}", err);
Err(AdvancedCommonWebError::from_error(err))
}
}
}
@@ -0,0 +1,76 @@
use log::{error, warn};
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
pub primary_order_id: &'a str,
pub maybe_additional_order_ids: Option<&'a [String]>,
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
let InsertSeedance2proJobsArgs {
primary_order_id,
maybe_additional_order_ids,
maybe_wallet_ledger_entry_token,
mut shared,
} = args;
// Build a deduplicated list with primary_order_id first.
let mut all_order_ids = vec![primary_order_id.to_string()];
if let Some(additional) = maybe_additional_order_ids {
for id in additional {
if id != primary_order_id {
all_order_ids.push(id.clone());
}
}
}
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
for (i, order_id) in all_order_ids.iter().enumerate() {
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_wallet_ledger_entry_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => all_job_tokens.push(token),
Err(err) => {
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
if i == 0 { return Err(AdvancedCommonWebError::from_error(err)); }
}
}
}
all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
})
}
@@ -0,0 +1,3 @@
pub mod insert_fal_job;
pub mod insert_seedance2pro_jobs;
pub mod shared_job_args;
@@ -0,0 +1,14 @@
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
pub struct SharedJobArgs<'a, 'tx> {
pub apriori_job_token: &'a InferenceJobToken,
pub idempotency_token: &'a str,
pub user_token: &'a UserToken,
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
pub maybe_prompt_token: Option<&'a PromptToken>,
pub ip_address: &'a str,
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
}
@@ -1,8 +1,5 @@
pub mod omni_gen_video_generate_handler;
pub mod distill_video_request;
pub(crate) mod distill_helper;
mod execute;
mod request_helper;
#[cfg(test)]
mod tests;
pub mod helpers;
pub mod pipeline_v1;
pub mod pipeline_v2;
pub mod insert_db_job;
@@ -9,37 +9,32 @@ use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::api::provider::Provider;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::distill_video_request;
use crate::http_server::endpoints::omni_gen::generate::video::execute::execute_generation::execute_generation;
use crate::http_server::endpoints::omni_gen::generate::video::request_helper::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::run_pipeline_v1::{run_pipeline_v1, RunPipelineV1Args};
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
@@ -63,7 +58,7 @@ pub async fn omni_gen_video_generate_handler(
request: Json<OmniGenVideoCostAndGenerateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenVideoGenerateResponse>, AdvancedCommonWebError> {
info!("request: {:?}", request);
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
@@ -85,11 +80,18 @@ pub async fn omni_gen_video_generate_handler(
AdvancedCommonWebError::from(e)
})?;
let user_token = match maybe_user_session.as_ref() {
Some(session) => &session.user_token,
let session = match maybe_user_session.as_ref() {
Some(session) => session,
None => return Err(AdvancedCommonWebError::NotAuthorized),
};
let user_token = &session.user_token;
let user_feature_flags =
UserSessionFeatureFlags::new(session.maybe_feature_flags.as_deref());
// ==================== MODEL ACCESS CHECK ==================== //
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
@@ -112,8 +114,6 @@ pub async fn omni_gen_video_generate_handler(
})?;
// ==================== RESOLVE MEDIA TOKENS ==================== //
// Look up media file tokens BEFORE distilling. distill_video_request takes
// a pre-computed `MediaFileToken -> Url` map and does no I/O of its own.
let media_file_hydration_map: Option<HashMap<MediaFileToken, Url>> = {
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
@@ -157,73 +157,67 @@ pub async fn omni_gen_video_generate_handler(
}
};
// ==================== RESOLVE CHARACTER TOKENS ==================== //
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> =
media_file_hydration_map.as_ref().map(|map| {
map.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()
});
let kinovi_character_ids = resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?;
// ==================== RESOLVE CHARACTERS ==================== //
// ==================== DETERMINE PROVIDER ==================== //
let execution_provider = match request.model {
Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro,
Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro,
_ => Provider::Fal,
};
// ==================== DISTILL ==================== //
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref(), execution_provider)?;
info!("distilled plan: {:?}", distilled.plan);
// ==================== BILLING ==================== //
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
info!("Charging wallet: {} credits", cost);
let apriori_job_token = InferenceJobToken::generate();
let maybe_deduction_result = if cost > 0 {
Some(attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost,
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?)
} else {
None
).await?;
// ==================== HYDRATE ROUTER REQUEST ==================== //
let router_builder = hydrate_to_router_request(&request)?;
// ==================== PIPELINE DISPATCH ==================== //
let use_v2 = match request.model {
Some(CommonVideoModel::HappyHorse1p0) => true,
Some(CommonVideoModel::Seedance2p0) => true,
Some(CommonVideoModel::Seedance2p0Fast) => true,
_ => false,
};
// ==================== EXECUTE GENERATION ==================== //
let pipeline_result = if use_v2 {
info!("Using pipeline v2");
run_pipeline_v2(RunPipelineV2Args {
router_builder: &router_builder,
server_state: &server_state,
mysql_connection: &mut mysql_connection,
user_token,
media_file_to_url_map: &media_file_to_url_map,
kinovi_character_id_map: &kinovi_character_id_map,
}).await?
} else {
info!("Using pipeline v1");
run_pipeline_v1(RunPipelineV1Args {
request: &request,
router_builder: &router_builder,
server_state: &server_state,
mysql_connection: &mut mysql_connection,
user_token,
media_url_map: &media_file_hydration_map,
kinovi_character_id_map: &kinovi_character_id_map,
}).await?
};
let gen_result = execute_generation(
&distilled,
&request,
&server_state,
media_file_hydration_map.as_ref(),
kinovi_character_ids,
maybe_deduction_result.as_ref().map(|d| &d.ledger_entry_token),
&mut mysql_connection,
).await?;
// ==================== DB TRANSACTION ==================== //
// ==================== WRITE RESULT ==================== //
let ip_address = get_request_ip(&http_request);
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
// -- Prompt --
let prompt_result = insert_prompt(InsertPromptArgs {
let prompt_token = match insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: Some(user_token),
@@ -232,18 +226,16 @@ pub async fn omni_gen_video_generate_handler(
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: request.negative_prompt.as_deref(),
maybe_other_args: None,
maybe_generation_mode: Some(gen_result.generation_mode),
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_generation_mode: Some(determine_generation_mode(&request)),
maybe_aspect_ratio: request.aspect_ratio,
maybe_resolution: request.resolution,
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
maybe_generate_audio: request.generate_audio,
maybe_duration_seconds: request.duration_seconds.map(|d| d as u32),
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
let prompt_token = match prompt_result {
}).await {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
@@ -262,14 +254,12 @@ pub async fn omni_gen_video_generate_handler(
context_semantic_type: PromptContextSemanticType::VidStartFrame,
});
}
if let Some(media_token) = &request.end_frame_image_media_token {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidEndFrame,
});
}
if let Some(ref_tokens) = &request.reference_image_media_tokens {
for media_token in ref_tokens {
context_items.push(PromptContextItem {
@@ -278,7 +268,6 @@ pub async fn omni_gen_video_generate_handler(
});
}
}
if let Some(ref_tokens) = &request.reference_video_media_tokens {
for media_token in ref_tokens {
context_items.push(PromptContextItem {
@@ -301,92 +290,49 @@ pub async fn omni_gen_video_generate_handler(
// -- Inference job --
let maybe_ledger_entry_token = maybe_deduction_result.as_ref()
.map(|d| &d.ledger_entry_token);
let job_token = if gen_result.is_seedance2pro {
// Seedance2Pro path: insert one job per order_id (batch support).
let fallback_ids = vec![gen_result.external_job_id.clone()];
let order_ids = gen_result.maybe_seedance_order_ids
.as_deref()
.unwrap_or(&fallback_ids);
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(order_ids.len());
for (i, order_id) in order_ids.iter().enumerate() {
let job_token = if i == 0 {
apriori_job_token.clone()
} else {
InferenceJobToken::generate()
};
let idempotency_str = if i == 0 {
idempotency_token.clone()
} else {
format!("{}-batch-{}", idempotency_token, i)
};
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_wallet_ledger_entry_token: maybe_ledger_entry_token,
maybe_creator_user_token: Some(user_token),
let job_token = match &pipeline_result.response {
GenerateVideoResponse::Seedance2Pro(payload) => {
info!("Inserting seedance2pro job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
primary_order_id: &payload.order_id,
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => all_job_tokens.push(token),
Err(err) => {
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
if i == 0 {
return Err(AdvancedCommonWebError::from_error(err));
}
}
}
maybe_prompt_token: prompt_token.as_ref(),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?
}
all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
})?
} else {
// Fal / other providers path.
// TODO: Pass maybe_wallet_ledger_entry_token to fal jobs once the fal insert supports it.
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: &apriori_job_token,
uuid_idempotency_token: &idempotency_token,
maybe_external_third_party_id: &gen_result.external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut *transaction,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => token,
Err(err) => {
warn!("Error inserting inference job: {:?}", err);
return Err(AdvancedCommonWebError::from_error(err));
}
GenerateVideoResponse::Fal(payload) => {
let external_id = payload.request_id.as_deref().ok_or_else(|| {
error!("Fal generation response missing request_id");
AdvancedCommonWebError::server_error_with_message("Fal generation response missing request_id")
})?;
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
insert_fal_job(InsertFalJobArgs {
external_job_id: external_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_prompt_token: prompt_token.as_ref(),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?
}
GenerateVideoResponse::Artcraft(payload) => {
payload.inference_job_token.clone()
}
other => {
error!("Unexpected generation response variant: {:?}", other);
return Err(AdvancedCommonWebError::server_error_with_message("Unexpected generation response"));
}
};
@@ -400,3 +346,23 @@ pub async fn omni_gen_video_generate_handler(
inference_job_token: job_token,
}))
}
fn determine_generation_mode(request: &OmniGenVideoCostAndGenerateRequest) -> CommonGenerationMode {
let has_keyframe = request.start_frame_image_media_token.is_some()
|| request.end_frame_image_media_token.is_some();
if has_keyframe {
return CommonGenerationMode::Keyframe;
}
let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|| request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|| request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|| request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty());
if has_reference {
return CommonGenerationMode::Reference;
}
CommonGenerationMode::Text
}

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