Compare commits

..
Author SHA1 Message Date
Tanish Manku 02b5435884 PageScene: extract Object Panel color picker into actions/setObjectColor
Closes the last UI-layer direct ColorAction construction. Same shape
as the toggleObjectLock / toggleObjectVisibility / transformObject
helpers — the view passes editor + uuid + new value; the helper
reads the before-state from the live engine, applies the change, and
records the undo entry.

Bonus correctness: before-state now comes from obj.userData.color
instead of the panel's local React `color` state. The two diverge if
the user undoes a color change (engine reverts but the local React
ref doesn't), so the engine-read shape produces the right diff
entries even when the panel state is stale.
2026-05-01 04:02:20 -07:00
Tanish Manku 5e8bb32692 PageScene: extract Outliner lock/visibility into actions/ helpers
Outliner.tsx imported LockAction and VisibilityAction directly and
mixed engine + Zustand + history calls in its click handlers,
breaking the layering convention the rest of actions/ follows
(addObject, transformObject, etc. — UI calls one helper; the helper
owns engine + store + history orchestration).

actions/toggleObjectLock and actions/toggleObjectVisibility now wrap
the engine action class + store toggle + history record in one
function. Each reads the before-state from the live engine so the
view doesn't have to plumb it.

Outliner.tsx drops the engine-action imports; handlers shrink to a
single call.

Color picker recording in ControlPanelSceneObject still constructs
ColorAction directly. Same pattern applies (actions/setObjectColor)
— easy follow-up if full layering symmetry is wanted.
2026-05-01 03:59:03 -07:00
Tanish Manku 3873dae52d PageScene: extract panel transform recording into actions/transformObject
The .tsx file shouldn't construct UndoableAction instances or call
HistoryManager directly — those are engine-level concerns that other
action helpers (addObject, addCharacter, addShape) already keep behind
the actions/ layer.

actions/transformObject.ts exposes beginTransformSession(editor, uuid)
which returns a session handle with one method, commit(). Open the
session on first edit, call commit() on selection change / panel
unmount. The session:
- snapshots the before-state in the constructor (via TransformAction)
- on commit, captures after-state and pushes a single undo entry only
  if the transform actually changed
- always re-syncs the Zustand objectPanel slice from the engine on
  commit so currentSceneObject reflects what the user just committed

ControlPanelSceneObject swaps its TransformAction import + manual
record/commit logic for the session helper. The view only owns the
"when does a session begin/end" boundary now; the action layer owns
the recording + store sync.
2026-05-01 03:50:21 -07:00
Tanish Manku 4956313723 PageScene: record Object Panel transform inputs via TransformAction
Closes the last undo/redo feature-parity gap vs. main. Main captured
panel-input transforms via the mouseup-diff in check_scene_for_updates
(only worked when the user happened to fire a subsequent mouseup);
this branch had no equivalent until now.

Implementation:
- A useRef holds a pending TransformAction. beginPanelTransform()
  constructs one on the first detected change for the currently-shown
  object — the constructor captures the before-state of the live
  scene object before the inputsUpdated useEffect dispatches the
  engine write.
- All three change handlers (position / rotation / scale, including
  the uniform-scale path) call beginPanelTransform() when an actual
  diff is detected.
- The selection-change useEffect's cleanup function runs
  commitPanelTransform() — captures the after-state and pushes the
  entry only if the transform actually changed. This fires both on
  selection switch (panel reloads for a new object) and on unmount.

The TransformAction stashes the uuid at construction, so commit
correctly targets the original object even if the panel has already
reloaded for a new selection by the time cleanup runs.
2026-05-01 03:44:38 -07:00
Tanish Manku 3c8fa9010b PageScene: SaveManager takes Deps, no Editor reference
Resolves the longstanding `// TODO Refactor remove editor.` Drops
the Editor import; constructor takes a SaveManagerDeps surface
inline-built by Editor (Phase 2 idiom — same shape as
CameraControllerDeps, GizmoControllerDeps, etc.).

Deps surface (10 fields, single-purpose):
- getVersion / setVersion — engine version (load may upgrade)
- getActiveScene — proxy serialization target
- getRenderer — for save thumbnail
- removeTransformControls — pre-save cleanup
- getCamera / refreshCamObj / changeRenderCameraAspectRatio — camera
- setPositivePrompt — restore prompt on load
- saveSceneState / loadSceneState — backend API

current_scene_media_token moves into SaveManager as private state
(only the load path used it; nothing outside SaveManager read it).
The field on Editor is dropped along with the two `= null` writes
in the constructor and initialize.

CameraController gains refreshCamObj(scene) — re-anchors cam_obj to
the named entity in the scene + sets layers. Replaces the inline
post-load setup that SaveManager used to do directly. Editor.initialize
also routes through the same method now (was inline at line ~410).
2026-05-01 03:40:50 -07:00
Tanish Manku 8780935b62 PageScene: strip dead code from SaveManager and Editor
Pure removals, no behavior change. Sets up the SaveManager Deps
refactor in the next commit.

SaveManager:
- Drop unused saveData (the live caller is getSceneJson + saveScene
  which goes through saveSceneState directly).
- Drop unused computeSceneChecksum + checkSumData + the
  computeHashForBrowser helper. Zero callers anywhere.
- Drop the editor.generating_preview = true/false bracket inside
  saveScene. The field has no readers — all the noise about "stop
  control panel from flipping out" is dead with VICCCCCCCCCCCCCCCTORRRRRRRR.

Editor:
- Drop generating_preview field (no readers anywhere).
- Drop current_scene_glb_media_token field + the two writes (no
  readers anywhere; suspected leftover of an older save format).
2026-05-01 03:38:07 -07:00
Tanish Manku 3681f4faef PageScene: route shape recreate through the geometry key
Shapes redo'd to broken / invisible objects because the snapshot
captured obj.name = "Cube" (the display label addShape sets after
creation), but scene.instantiate's geometry switch only matches the
geometry key ("Box", "Sphere", "PointLight", ...). Calling
instantiate("Cube", pos) fell through with no matching geometry.

Fix: addShape stashes the geometry key in userData.shapeKey before
overriding obj.name. recreateFromSnapshot picks shapeKey when
present (falls back to snap.name for non-shape paths), then restores
obj.name = snap.name after the create returns so the display label
sticks. Non-shape recreations are unaffected — their userData has no
shapeKey, so the fallback to snap.name keeps the existing flow.
2026-05-01 03:26:31 -07:00
Tanish Manku 62094e5a33 PageScene: drop recordLockChange from SelectionBridge deps
After the migration to LockAction, recording is the call site's
responsibility (Outliner.tsx pushes a LockAction directly). Drop
the recordLockChange dep callback and the bridge's auto-record
inside lockUnlockObject — the bridge stays history-agnostic.
2026-05-01 03:03:01 -07:00
Tanish Manku 28b7444697 PageScene: migrate call sites to UndoableAction; strip legacy HistoryContext
Every recording site now constructs an action class and passes it to
record(). HistoryManager loses all per-kind methods, ctx/HistoryContext,
HistoryEntry, the wrap helper, the legacy entry factories, and the
pendingTransform field — down to a generic stack with one external
method (record) plus undo/redo.

editor.ts shrinks dramatically: the ~70-line inline HistoryContext
deps blob is gone, replaced by `new HistoryManager({ capacity: 64 })`
on its own. Gizmo onDraggingChanged now stores a TransformAction
between dragstart and dragend; commits and records via record(...) at
dragend. SelectionBridgeDeps drops recordLockChange — lock recording
moves to the Outliner call site via new LockAction(...).

Migration map:
- actions/addObject, addCharacter, addShape:
    history.recordCreate(obj) → history.record(new CreateAction(editor, obj))
- editor.deleteObject:
    history.recordDelete(obj) → history.record(new DeleteAction(editor, obj))
- editor.ts gizmo onDraggingChanged:
    begin/endTransform → activeTransform: TransformAction with commit()
- keymap.ts paste:
    history.recordCreate(obj) → history.record(new CreateAction(editor, obj))
- comps/Outliner/Outliner.tsx:
    lock — new LockAction(selection, uuid, before, after)
    visibility — new VisibilityAction(editor, uuid, before, after)
- ControlPanelSceneObject color picker:
    history.recordSetColor(...) → history.record(new ColorAction(editor, uuid, before, after))

LockAction and VisibilityAction self-refresh the outliner after
apply/revert, matching the pattern CreateAction/DeleteAction already
use. HistoryManager has no global onReplay refresh hook — actions
that need UI sync handle it themselves.

The "adding a new action" test: a future "duplicate object" action is
just a new file under engine/editor/actions/DuplicateAction.ts
implementing UndoableAction. Zero edits to HistoryManager, zero edits
to editor.ts.
2026-05-01 03:02:08 -07:00
Tanish Manku f4005603f6 PageScene: add concrete UndoableAction classes
Six self-contained classes under engine/editor/actions/, one per
action kind. Each implements UndoableAction, captures its own state
in the constructor, and runs apply/revert against subsystem refs
held privately. Adding a new kind = a new file here, no edits
elsewhere.

- CreateAction: snapshots an obj, recreates on apply (redo),
  removes via utils.deleteObject on revert (undo). Uses the
  non-recording delete path so undo of create can't recurse.
- DeleteAction: mirror — removes on apply, recreates on revert.
- TransformAction: encapsulates begin/end transaction state. Caller
  constructs at dragstart (captures before), calls commit() at
  dragend to capture after + check for a no-op move. records only
  if commit() returns true.
- ColorAction: simple before/after pair, routes through
  activeScene.setColor.
- LockAction: routes through SelectionBridge.setLockState so the
  gizmo attach/detach side effects fire on undo.
- VisibilityAction: writes obj.visible + userData.visible directly.

No call sites use these yet — that's the next commit. HistoryManager
is unchanged; it already accepts UndoableAction via record().
2026-05-01 02:57:28 -07:00
Tanish Manku a4c3be4c99 PageScene: introduce UndoableAction polymorphism + isReplaying guard
The user critique on the original HistoryManager design was sharp:
adding a new action kind required edits to four places (HistoryContext
interface, deps wiring in editor.ts, entry factory, recorder method).
Anything shared between kinds becomes a chokepoint that grows.

Introduce the proper Command pattern:

- UndoableAction interface — single shape every action class
  implements. HistoryManager's external API becomes record(action) +
  undo/redo. Future kinds add a new class file under
  engine/editor/actions/; HistoryManager itself never grows.
- isReplaying flag — record() no-ops while undo/redo is mid-flight,
  so any side-effect chain inside an action's apply/revert (e.g.
  delete-undo reaching editor.deleteObject which calls
  history.recordDelete) can't poison the stack. Belt-and-suspenders
  with the per-kind silent-setter routes the action classes will use.
- engine/editor/actions/snapshots.ts — shared ObjectSnap /
  TransformSnap types, snapshot helpers, transformsEqual,
  writeTransform, and recreateFromSnapshot. Used by the action
  classes added in the next commit.

Legacy recordCreate / recordDelete / begin+endTransform /
recordSetColor / recordSetLocked / recordSetVisible methods stay as
thin wrappers that bind the current HistoryContext into an
UndoableAction via wrap(). Call sites compile unchanged. They're
removed once all sites migrate to record(new XAction(...)).
2026-05-01 02:56:16 -07:00
Tanish Manku d9680ae0a8 PageScene: route lock undo through gizmo side effects
SelectionBridge.lockUnlockObject toggled userData.locked AND
attached/detached the gizmo, but HistoryContext.setLocked only did
the userData write. After Ctrl+Z the lock flag reverted but the
gizmo state stayed wrong.

Extract the side-effect chain (setObjectLocked + gizmo attach/detach
+ updateSelectedUI) into a private SelectionBridge.applyLockState
helper. Both lockUnlockObject (toggle path) and the new
setLockState (history-replay path) call it. Add a SceneUtils
direct-set helper (setObjectLocked) so applyLockState can target a
specific state instead of toggling.

Wire HistoryContext.setLocked through selection.setLockState so undo
of a lock toggle re-runs the gizmo attach/detach. The
recordLockChange dep stays for now; it moves to the call site in a
later commit when the action layer migrates to LockAction.
2026-05-01 02:54:19 -07:00
Tanish Manku 4a837d49a7 PageScene: apply position to non-shape loaders
Four loader paths in scene.ts passed `position` to the placeholder
manager but never copied it onto the actual loaded object — so a
GLB / MMD / Splat / image / video drop showed the placeholder at the
cursor and then jumped to (0, 0, 0) when the asset finished loading.
Shapes were unaffected because Scene.instantiate already does
`obj.position.copy(pos)`.

Mirror the existing loadObjectFromUrl template (line 553-555):
- loadObject image/video branch — copy position onto the result of
  instantiate("Image::"+url)
- loadMMDWithPlaceholder — copy onto mmd before scene.add
- loadSplatWithPlaceholder — copy onto splat before scene.add
- loadGlbWithPlaceholder — copy onto child_result before scene.add
2026-05-01 02:53:04 -07:00
Tanish Manku 3f3da24ab6 PageScene: strip legacy diff-based undo machinery from SceneManager
Now that every mutation site records explicitly through HistoryManager,
SceneManager no longer needs to own an undo stack or reverse-engineer
edits from per-mouseup scene-state diffs.

Removed from SceneManager:
- undoStack, undoIndex, lastSceneState, copiedObject is kept
- undo, redo, dropRedoHistory methods
- getSceneState, is_creation, is_deletion, is_userdata, is_transformation
- add_creation_undostack, check_scene_for_updates
- onMouseMove/onMouseClick/onKeyDown/onMouseDown/onMouseUp wrappers
  (no callers — pointer events now go canvas → useViewportPointer →
  mouse_controls directly)
- attachEventListeners/detachEventListeners (already empty stubs)
- Constructor params: devMode, updateOutliner, is_character (unused
  after the gut)
- updateOutliner field — paste() refresh moved to keymap's paste handler

Also:
- engine/Commands.ts deleted — its CreationSceneItem / DeletionSceneItem
  / TransformSceneItem / UserDataSceneItem and supporting types are
  obsolete (HistoryManager owns its own entry factories).
- useViewportPointer.onPointerUp reverts to mouse_controls.onMouseUp
  directly (the sceneManager indirection only existed to call
  check_scene_for_updates).
- Editor.initialize drops attachEventListeners/detachEventListeners
  calls and the three trailing constructor args.

scene_manager_api.ts: 477 → 265 LOC. SceneManagerAPI interface trims
to what it actually does — object lifecycle (create/retrieve/delete/
update), selection, outliner rendering. Object Panel transform-input
recording is the one remaining gap from the old diff path; tracked
as a follow-up.
2026-05-01 01:53:41 -07:00
Tanish Manku 39f4c84241 PageScene: record color changes via HistoryManager
Color picker is the third interactive surface (after gizmo drag and
outliner toggles) that mutates an object. Wire it the same way:
record in the onChange handler (input type="color" fires once per
committed pick, so one undo entry per user choice).

Object Panel transform inputs (position/rotation/scale text fields)
are still on the legacy diff path. Wiring them is one more pass —
they need a coalescer because each keystroke flushes to the engine.
Tracking that as a follow-up.
2026-05-01 01:49:11 -07:00
Tanish Manku bab0f62973 PageScene: record gizmo transforms via HistoryManager
The gizmo's onDraggingChanged fires once with dragging=true at
dragstart and once with dragging=false at dragend. Wire those
boundaries to history.beginTransform / endTransform so each drag
becomes one coalesced undo entry. No-op moves (mouse-down + drag-up
without movement) are dropped automatically by HistoryManager.

Object Panel inputs and color edits still ride the legacy
check_scene_for_updates diff path; those are migrated separately
before the diff machinery is removed.
2026-05-01 01:47:05 -07:00
Tanish Manku 6632dd34cf PageScene: route create/delete/lock/visible/paste through HistoryManager
Wires creation, deletion, lock, visibility, and paste through the new
HistoryManager. Mutator-recorded — every external call site keeps
the same signature it had before; recording happens inside the mutator
or its dep callback, so callers don't grow new responsibilities.

Recordings added:
- actions/addObject, addCharacter, addShape: editor.history.recordCreate(obj)
  replaces the legacy add_creation_undostack call.
- editor.deleteObject: snapshots the target before delegating to
  utils.deleteObject so undo can fully reconstruct it.
- SelectionBridge.lockUnlockObject: takes a recordLockChange dep
  callback, records before/after locked state inline. No coupling
  back to HistoryManager from the bridge.
- Outliner.handleToggleVisibility: records visible before/after at the
  call site (hideObject lives on SceneManager which has no history dep).
- SceneManager.paste: now returns the new Object3D so keymap's paste
  handler can record it via editor.history.recordCreate. The legacy
  add_creation_undostack call inside paste is removed.

keymap.ts: undo/redo target editor.history; deleteSelected routes
through editor.deleteObject (so history records); paste captures the
returned object and records.

Transform recording (gizmo drag, panel inputs) and color stay on the
old check_scene_for_updates diff path until follow-up commits. Old
SceneManager undo machinery (undoStack, undo/redo methods,
add_creation_undostack, the diff helpers) is still in place but only
the diff path drives transforms now.
2026-05-01 01:46:21 -07:00
Tanish Manku 94430a8817 PageScene: introduce HistoryManager subsystem
New peer subsystem under engine/editor/ that owns the undo/redo stack.
Lands the foundation only — old SceneManager-based undo machinery
still drives undo/redo for now. A follow-up commit migrates every
mutation site over and removes the legacy diffing.

Design:
- Pure-data HistoryEntry: { label, apply(ctx), revert(ctx) }. Entries
  receive a HistoryContext when replayed; they don't hold subsystem
  references. Easy to test in isolation, immune to subsystem
  re-creation.
- The external API is the recordX methods (recordCreate, recordDelete,
  begin/endTransform, recordSetColor/Locked/Visible). Mutation sites
  call ONE method — entry construction stays in the manager. Adding
  a future action is "add an entry factory + a recorder method"; no
  churn at action sites.
- Async-serialized replay via a Promise chain: a Ctrl+Z mash during
  an in-flight asset reload can't interleave entry replays.
- onChange callback hook for a future store-bridge to publish
  canUndo/canRedo into PageSceneStore.
- Capacity 64 (vs. main's 16); transform/userdata no-ops are dropped.

Editor wires the HistoryContext inline in its constructor (Phase 2
deps idiom — same shape as CameraControllerDeps etc.). Closures over
`this` resolve sceneManager/activeScene lazily so construction order
inside the editor constructor doesn't matter.
2026-05-01 01:41:36 -07:00
Tanish Manku 51e9f046e2 PageScene: push creation entries to undo stack from add* actions
Pre-refactor, the queue handler (timeline.addShape/addObject/addCharacter)
called sceneManager.add_creation_undostack(obj) after creating the
THREE.Object3D. The actions/ extraction collapsed the queue path but
forgot to carry the undo push along, so dragging a new asset onto the
3D canvas left the undo stack untouched.

Push a CreationSceneItem from each of addObject, addCharacter, and
addShape after the engine call succeeds. check_scene_for_updates only
detects transforms / deletions / userdata diffs on mouseup; it does
not record creations, which is why this has to be an explicit call.
2026-05-01 01:22:24 -07:00
Tanish Manku 6721d2ded8 PageScene: route viewport pointerup through sceneManager
useViewportPointer.onPointerUp called editor.mouse_controls.onMouseUp
directly. That ran the interaction state machine (gizmo end, FK toggle,
selection click) but skipped editor.sceneManager.onMouseUp, which is
the only thing that calls check_scene_for_updates. Result: transform,
deletion, and userdata edits (color, lock, visibility) never made it
into the undo stack, so Ctrl+Z found nothing to undo.

Pre-refactor, SceneManager.attachEventListeners attached a window-level
mouseup that went through SceneManager.onMouseUp; the canvas-scoped
hook lost the same pass-through. Calling sceneManager.onMouseUp here
preserves all the mouse_controls behavior (it forwards internally) and
restores the post-mouseup undo capture.
2026-05-01 01:22:14 -07:00
Tanish Manku aff4a5616e PageScene: read camera/freeCamState/focalLengthToFov from CameraController
Phase 1 moved camera, freeCamState, and focalLengthToFov off Editor and
onto CameraController, but two camera-update blocks in PageEditor
(handleSelectCamera and handleAddCamera) still read editorEngine.camera,
editorEngine.focalLengthToFov, and editorEngine.freeCamState. Those
fields are undefined now, so the cam-update guards short-circuited and
the reset/random-camera buttons silently did nothing.

Sweep both blocks to read from editorEngine.cameraController.* and
collapse the chain into a local `cam` const.
2026-05-01 01:11:40 -07:00
Tanish Manku b95f6fdf91 PageScene: restore raycaster-based drop position
DndAsset.onPointerUp captured event.pageX/Y but threw them away; both
add* actions then defaulted position to origin. PageEditor's gallery
onImageDrop tried an NDC unproject but gated on editorEngine.camera,
which Phase 1's CameraController extraction left undefined — the guard
short-circuited and worldPosition stayed undefined.

Add pickDropPosition: a deps-pattern helper that mirrors the
pre-refactor timeline.getPos() — raycasts from the camera through the
cursor against scene children and returns the first hit (or origin if
the ray misses or the camera/canvas isn't ready). Same idiom as
CameraControllerDeps / GizmoControllerDeps from Phase 2; callers build
the deps inline so the helper stays decoupled from Editor and any
subsystem's import surface.

Both drop sites — DndAsset.onPointerUp and PageEditor's 3D gallery
onImageDrop — now call the helper and forward worldPosition to
addCharacter / addObject / addShape.
2026-05-01 01:11:11 -07:00
Tanish Manku 3af5291150 PageScene: drop dead create_parim helper
create_parim was a one-line passthrough to activeScene.instantiate
that the pre-refactor queue path used. After actions/addShape was
rewired to call sceneManager.create with the "Parim" sentinel,
create_parim has zero callers anywhere in the artcraft frontend.
2026-05-01 01:10:20 -07:00
Tanish Manku 2710e875ec PageScene: route addShape through "Parim" sentinel so shapes appear
actions/addShape passed item.media_id ("Box", "Sphere", ...) as the
routing token to SceneManager.create. That hits the
loadObject(media_token, ...) branch and tries to fetch a media URL
for "Box", which fails — the shape never lands in the scene.

Pass "Parim" as the routing sentinel and item.media_id as the
geometry name so create() forwards to scene.instantiate(name, pos),
which has the geometry switch ("Box" -> BoxGeometry, etc.). Then
overwrite obj.name with the display label so the outliner reads
"Cube" instead of "Box".

Restores the pre-Phase-4 behavior of editorEngine.create_parim(media_id, pos)
that the queue path used.
2026-05-01 01:10:14 -07:00
Tanish Manku 5e05877619 3D editor.ts cleanup (#1461) 2026-05-01 00:10:50 -07:00
Tanish Manku fec717057e Re-add shaders.js used by chromakey.js 2026-04-30 14:32:53 -07:00
Tanish Manku 9ad3b86524 Audio: Move sound system into SoundManager, fix bootstrap + master toggle (#1455)
- 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:07:28 -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
596 changed files with 7590 additions and 25841 deletions
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 17
"Right": 16
},
"nullable": []
},
"hash": "c73be10c5677f78f81bc1c29872fd5af889790c3b2a8de27eb8ab64383c638d6"
"hash": "29916e73a1784ea0b70ef4f637520b05efccc31e01840f681e9b042f82eb8f7e"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
},
"nullable": []
},
"hash": "8f8b64c0af5383ff3baaa8aa2f2aa22cc760addd850d618ce543fe05816d11e0"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
@@ -125,16 +125,6 @@
},
{
"ordinal": 12,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 13,
"name": "maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory",
"type_info": {
"type": "VarString",
@@ -144,7 +134,7 @@
}
},
{
"ordinal": 14,
"ordinal": 13,
"name": "failure_reason",
"type_info": {
"type": "VarString",
@@ -154,7 +144,7 @@
}
},
{
"ordinal": 15,
"ordinal": 14,
"name": "maybe_result_entity_type",
"type_info": {
"type": "VarString",
@@ -164,7 +154,7 @@
}
},
{
"ordinal": 16,
"ordinal": 15,
"name": "maybe_result_entity_token",
"type_info": {
"type": "VarString",
@@ -174,7 +164,7 @@
}
},
{
"ordinal": 17,
"ordinal": 16,
"name": "maybe_result_batch_token",
"type_info": {
"type": "VarString",
@@ -184,7 +174,7 @@
}
},
{
"ordinal": 18,
"ordinal": 17,
"name": "maybe_model_weights_title",
"type_info": {
"type": "VarString",
@@ -194,7 +184,7 @@
}
},
{
"ordinal": 19,
"ordinal": 18,
"name": "maybe_tts_model_title",
"type_info": {
"type": "VarString",
@@ -204,7 +194,7 @@
}
},
{
"ordinal": 20,
"ordinal": 19,
"name": "maybe_voice_conversion_model_title",
"type_info": {
"type": "VarString",
@@ -214,7 +204,7 @@
}
},
{
"ordinal": 21,
"ordinal": 20,
"name": "maybe_tts_public_bucket_path",
"type_info": {
"type": "VarString",
@@ -224,7 +214,7 @@
}
},
{
"ordinal": 22,
"ordinal": 21,
"name": "maybe_voice_conversion_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -234,7 +224,7 @@
}
},
{
"ordinal": 23,
"ordinal": 22,
"name": "maybe_media_file_public_bucket_directory_hash",
"type_info": {
"type": "VarString",
@@ -244,7 +234,7 @@
}
},
{
"ordinal": 24,
"ordinal": 23,
"name": "maybe_media_file_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -254,7 +244,7 @@
}
},
{
"ordinal": 25,
"ordinal": 24,
"name": "maybe_media_file_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -264,7 +254,7 @@
}
},
{
"ordinal": 26,
"ordinal": 25,
"name": "maybe_assigned_worker",
"type_info": {
"type": "VarString",
@@ -274,7 +264,7 @@
}
},
{
"ordinal": 27,
"ordinal": 26,
"name": "maybe_assigned_cluster",
"type_info": {
"type": "VarString",
@@ -284,7 +274,7 @@
}
},
{
"ordinal": 28,
"ordinal": 27,
"name": "is_keepalive_required",
"type_info": {
"type": "Tiny",
@@ -294,7 +284,7 @@
}
},
{
"ordinal": 29,
"ordinal": 28,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -304,7 +294,7 @@
}
},
{
"ordinal": 30,
"ordinal": 29,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -314,7 +304,7 @@
}
},
{
"ordinal": 31,
"ordinal": 30,
"name": "maybe_first_started_at",
"type_info": {
"type": "Timestamp",
@@ -324,7 +314,7 @@
}
},
{
"ordinal": 32,
"ordinal": 31,
"name": "maybe_successfully_completed_at",
"type_info": {
"type": "Timestamp",
@@ -334,7 +324,7 @@
}
},
{
"ordinal": 33,
"ordinal": 32,
"name": "database_clock",
"type_info": {
"type": "Datetime",
@@ -375,7 +365,6 @@
true,
true,
true,
true,
false,
false,
false,
@@ -384,5 +373,5 @@
false
]
},
"hash": "6948edca3f06643605695665df51cc94f897e2cb9a2d6906910d58d4d56ee73e"
"hash": "a2c9e2f61eba666254f8432b3c50a0a7738ee3b3faa77c5281bc8e4af96159b9"
}
@@ -1,80 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -1,355 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n uuid_idempotency_token,\n job_type as `maybe_job_type`,\n product_category as `maybe_product_category`,\n inference_category,\n maybe_external_third_party,\n maybe_external_third_party_id,\n maybe_model_type,\n maybe_model_token,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_inference_args,\n maybe_download_url,\n on_success_result_entity_type,\n on_success_result_entity_token,\n on_success_result_batch_token,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token,\n creator_ip_address,\n creator_set_visibility,\n maybe_wallet_ledger_entry_token,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n status,\n attempt_count as `attempt_count: u16`,\n failure_reason,\n internal_debugging_failure_reason,\n frontend_failure_category,\n maybe_routing_tag,\n assigned_worker,\n assigned_cluster,\n created_at,\n updated_at\nFROM generic_inference_jobs\nWHERE token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "uuid_idempotency_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 144
}
},
{
"ordinal": 2,
"name": "maybe_job_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_product_category",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "inference_category",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_external_third_party",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 7,
"name": "maybe_model_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_model_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_inference_args",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 262140
}
},
{
"ordinal": 11,
"name": "maybe_download_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 4096
}
},
{
"ordinal": 12,
"name": "on_success_result_entity_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 13,
"name": "on_success_result_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 14,
"name": "on_success_result_batch_token",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_creator_anonymous_visitor_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 17,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 18,
"name": "creator_set_visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 19,
"name": "maybe_wallet_ledger_entry_token",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 20,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 21,
"name": "status",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 22,
"name": "attempt_count: u16",
"type_info": {
"type": "Short",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 23,
"name": "failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 24,
"name": "internal_debugging_failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 25,
"name": "frontend_failure_category",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 26,
"name": "maybe_routing_tag",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 27,
"name": "assigned_worker",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 512
}
},
{
"ordinal": 28,
"name": "assigned_cluster",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 512
}
},
{
"ordinal": 29,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 30,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "e869f065e6f184abbabae04ecd0da6ea02c0576cc22ee55e47eb91dd34048ef1"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
@@ -85,16 +85,6 @@
},
{
"ordinal": 8,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -104,7 +94,7 @@
}
},
{
"ordinal": 10,
"ordinal": 9,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -126,10 +116,9 @@
true,
false,
true,
true,
false,
false
]
},
"hash": "2c98621c08055374fd09502c94ac40bb01d23fcea3f844cceea7446a5c0cb4dc"
"hash": "fbe482c8987231c3b5d75f2e792d0fdd033de8a7eb821428fa034752c73edfb7"
}
Generated
-5
View File
@@ -11469,18 +11469,13 @@ name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"clap 4.5.60",
"easyenv",
"enums",
"errors",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
"uuid 1.22.0",
]
[[package]]
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_quality;
@@ -1,8 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Add quality parameter to prompts table.
-- Represented by the enum `CommonQuality` (high, medium, low).
ALTER TABLE prompts
ADD COLUMN maybe_quality VARCHAR(16) DEFAULT NULL;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS debug_logs;
@@ -1,31 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE debug_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Event token. Not unique — multiple log rows can share the same event.
event_token VARCHAR(32) NOT NULL,
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_debug_log_event_token;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL;
@@ -1,31 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE debug_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Event token. Not unique — multiple log rows can share the same event.
event_token VARCHAR(32) NOT NULL,
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -109,7 +109,7 @@ CREATE TABLE generic_inference_jobs (
-- If we pre-constructed a prompt, this will be copied to the resultant media generated.
-- This is especially important for Fal-related jobs and endpoints. We need this for keeping
-- track of all the prompt details, context images, etc.
maybe_prompt_token VARCHAR(32) DEFAULT NULL,
maybe_prompt_token VARCHAR(30) DEFAULT NULL,
-- A migration flag we can control from the backend API service which will tell
-- the worker where to store the results. If false, GCP. If true, AWS.
@@ -204,10 +204,6 @@ CREATE TABLE generic_inference_jobs (
-- off special workers to handle certain jobs.
maybe_routing_tag VARCHAR(64) DEFAULT NULL,
-- Foreign key to the debug_logs table (event_token column).
-- Multiple debug_logs rows can share the same event token.
maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL,
-- ========== JOB SYSTEM DETAILS ==========
-- Jobs begin as "pending", then transition to other states.
@@ -68,10 +68,6 @@ CREATE TABLE prompts (
-- The resolution for the prompt (eg. four_k, 720p, etc.)
maybe_resolution VARCHAR(16) DEFAULT NULL,
-- Represented by the enum `CommonQuality`.
-- The quality setting (eg. high, medium, low)
maybe_quality VARCHAR(16) DEFAULT NULL,
-- The duration in seconds
maybe_duration_seconds INT UNSIGNED DEFAULT NULL,
@@ -1,11 +0,0 @@
$kind: http-request
name: Batch Get Prompt
url: "{{api_host}}/v1/prompts/prompt_8q8fwcmh93nn8pjb2szeqbxgz"
method: GET
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
order: 2171327660164107
@@ -1,17 +1,16 @@
use crate::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest};
use crate::common::responses::media_links::MediaLinks;
use chrono::{DateTime, Utc};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use serde::Deserialize;
use serde::Serialize;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::{IntoParams, ToSchema};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use tokens::tokens::batch_generations::BatchGenerationToken;
pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session";
@@ -54,9 +53,6 @@ pub struct ListSessionJobsItem {
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListSessionRequestDetailsResponse {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
@@ -6,12 +6,5 @@ use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenVideoGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests (eg. Seedance2Pro with
/// multiple order IDs) this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -1,71 +0,0 @@
use crate::prompts::get_prompt::GetPromptImageContextItem;
use chrono::{DateTime, Utc};
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use std::collections::HashSet;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::prompts::PromptToken;
use utoipa::{IntoParams, ToSchema};
pub const BATCH_GET_PROMPTS_PATH: &str = "/v1/prompt/batch";
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct BatchGetPromptsQuery {
/// Prompt tokens to look up.
pub tokens: HashSet<String>,
}
#[derive(Serialize, ToSchema)]
pub struct BatchGetPromptsResponse {
pub success: bool,
pub prompts: Vec<BatchPromptInfo>,
}
#[derive(Serialize, ToSchema)]
pub struct BatchPromptInfo {
pub token: PromptToken,
/// The type of model used
pub maybe_model_type: Option<CommonModelType>,
/// The class of model used
pub maybe_model_class: Option<CommonModelClass>,
/// The service provider used
pub maybe_generation_provider: Option<GenerationProvider>,
/// Positive prompt (technically optional, but usually present)
pub maybe_positive_prompt: Option<String>,
/// Negative prompt (optional)
pub maybe_negative_prompt: Option<String>,
/// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.)
pub maybe_generation_mode: Option<CommonGenerationMode>,
/// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.)
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
pub maybe_resolution: Option<CommonResolution>,
/// OPTIONAL. The number of outputs to generate (0-255).
pub maybe_batch_count: Option<u8>,
/// OPTIONAL. Whether to generate audio.
pub maybe_generate_audio: Option<bool>,
/// OPTIONAL. The duration in seconds.
pub maybe_duration_seconds: Option<u32>,
/// Context images (optional)
pub maybe_context_images: Option<Vec<GetPromptImageContextItem>>,
pub created_at: DateTime<Utc>,
}
@@ -27,6 +27,13 @@ pub struct GetPromptSuccessResponse {
pub prompt: PromptInfo,
}
#[derive(Serialize, ToSchema)]
pub struct GetPromptImageContextItem {
pub media_token: MediaFileToken,
pub semantic: PromptContextSemanticType,
pub media_links: MediaLinks,
}
#[derive(Serialize, ToSchema)]
pub struct PromptInfo {
pub token: PromptToken,
@@ -123,13 +130,6 @@ pub struct PromptInfo {
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct GetPromptImageContextItem {
pub media_token: MediaFileToken,
pub semantic: PromptContextSemanticType,
pub media_links: MediaLinks,
}
#[derive(Serialize, ToSchema)]
pub struct PromptInfoModeratorFields {
/// How many milliseconds it took to run generation.
@@ -1,3 +1,2 @@
pub mod batch_get_prompts;
pub mod create_prompt;
pub mod get_prompt;
@@ -1,6 +1,5 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use errors::AnyhowError;
use reqwest::StatusCode;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
@@ -31,12 +30,6 @@ pub enum ApiError {
backend_hostname: Option<String>,
},
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// Cloudflare errors.
CloudflareError(CloudflareError),
@@ -73,8 +66,6 @@ impl Display for ApiError {
ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg),
ApiError::InternalServerError {body, backend_hostname} =>
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
ApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body } =>
write!(f, "Uncategorized bad response with status code {} and body: {}", status_code, body),
// Server response handling errors
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
@@ -1,157 +0,0 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use log::{info, warn};
use reqwest::Client;
use url::Url;
use tokens::tokens::media_files::MediaFileToken;
use crate::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
pub struct DownloadMediaFileArgs<'a, P: AsRef<Path>> {
pub media_token: &'a MediaFileToken,
pub api_host: &'a ApiHost,
pub download_path: DownloadPath<P>,
}
pub enum DownloadPath<P: AsRef<Path>> {
/// Write the file to this exact path.
ExactFilename(P),
/// Write the file into this directory, generating a filename from the CDN URL extension.
Directory(P),
}
pub struct DownloadMediaFileResult {
/// The path the file was written to.
pub downloaded_file_path: PathBuf,
/// The full media file response from the API.
pub media_file_response: GetMediaFileSuccessResponse,
/// Size of downloaded file in bytes.
pub filesize_bytes: usize,
}
pub async fn download_media_file<P: AsRef<Path>>(
args: DownloadMediaFileArgs<'_, P>,
) -> Result<DownloadMediaFileResult, StorytellerError> {
let DownloadMediaFileArgs {
media_token,
api_host,
download_path
} = args;
// 1. Fetch media file info from the API.
let response = get_media_file(api_host, media_token).await?;
let media_class = &response.media_file.media_class;
let cdn_url = &response.media_file.media_links.cdn_url;
info!("Downloading media file {} of class {} from CDN: {}",
media_token.as_str(), media_class.to_str(), cdn_url);
// 2. Determine the output file path.
let output_path = match &download_path {
DownloadPath::ExactFilename(path) => path.as_ref().to_path_buf(),
DownloadPath::Directory(dir) => {
let filename = derive_filename_from_url(cdn_url, &media_token);
dir.as_ref().join(filename)
}
};
// 3. Download the bytes from the CDN.
let bytes = download_bytes(cdn_url).await?;
// 4. Write to disk.
let mut file = fs::File::create(&output_path)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.write_all(&bytes)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.flush()
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
Ok(DownloadMediaFileResult {
downloaded_file_path: output_path,
media_file_response: response,
filesize_bytes: bytes.len(),
})
}
// ── Helpers ──
async fn download_bytes(url: &Url) -> Result<Vec<u8>, StorytellerError> {
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
let response = client.get(url.as_str())
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::OtherReqwestError(err)))?;
let status_code = response.status();
if !status_code.is_success() {
let body = response.text().await.unwrap_or_else(|err| {
warn!("Failed to retrieve response body: {}", err);
"".to_string()
});
return Err(StorytellerError::Api(ApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}));
}
let bytes = response.bytes()
.await
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
Ok(bytes.to_vec())
}
/// Derive a filename from the CDN URL's path extension, falling back to the media token.
fn derive_filename_from_url(url: &Url, media_token: &MediaFileToken) -> String {
let path = url.path();
let extension = Path::new(path)
.extension() // NB: without dot '.'
.and_then(|ext| ext.to_str())
.unwrap_or("bin");
format!("{}.{}", media_token.as_str(), extension)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_filename_from_url_extracts_extension() {
let url = Url::parse("https://cdn.example.com/files/abc123.png").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.png");
}
#[test]
fn derive_filename_from_url_handles_no_extension() {
let url = Url::parse("https://cdn.example.com/files/abc123").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.bin");
}
#[test]
fn derive_filename_from_url_handles_query_params() {
let url = Url::parse("https://cdn.example.com/files/abc123.mp4?token=xyz").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.mp4");
}
}
@@ -1,2 +1 @@
pub mod download_media_file;
pub mod upload_media_file_from_file;
@@ -80,7 +80,6 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -101,6 +100,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
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,
@@ -222,10 +222,9 @@ mod tests {
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
Some(CommonResolution::TenEightyP),
// NB: These are underpriced (for now)
//Some(CommonResolution::SevenTwentyP),
//None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
@@ -81,7 +81,6 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
// Mismatches
Some(unsupported) => match strategy {
@@ -103,6 +102,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
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,
@@ -185,47 +185,47 @@ mod tests {
// -- 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,
// );
// }
// }
// }
// }
//}
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 --
@@ -1,8 +1,4 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
@@ -77,7 +73,6 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -100,6 +95,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
@@ -197,11 +193,7 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -1,23 +1,22 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount,
KinoviSeedance2p0OutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
pub struct KinoviSeedance2p0CostState {
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
pub resolution: KinoviOutputResolution,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0BatchCount>,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub has_video_reference: bool,
}
impl KinoviSeedance2p0CostState {
pub fn from_request(request: &KinoviSeedance2p0RequestState) -> Self {
Self {
resolution: request.request.output_resolution,
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
@@ -33,22 +32,27 @@ impl KinoviSeedance2p0CostState {
.is_some();
Self {
resolution: draft.resolution,
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
batch_count: draft.batch_count,
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let pricing_request = GenerateSeedance2p0Request {
output_resolution: self.resolution,
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Pro,
output_resolution: Some(self.resolution),
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: None,
aspect_ratio: KinoviAspectRatio::Portrait9x16,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -58,8 +62,8 @@ impl KinoviSeedance2p0CostState {
use_face_blur_hack: None,
};
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -75,11 +79,6 @@ impl KinoviSeedance2p0CostState {
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
};
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
@@ -94,26 +93,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 124);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 80);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 96);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 112);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 240);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 128);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 480);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 256);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 320);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 960);
}
}
@@ -203,9 +202,9 @@ mod tests {
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0CostState {
resolution: Some(KinoviOutputResolution::SevenTwentyP),
resolution: KinoviOutputResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: Some(KinoviBatchCount::One),
batch_count: KinoviBatchCount::One,
has_video_reference: false,
};
let without = base.estimate_cost();
@@ -223,19 +222,19 @@ mod tests {
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
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, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
@@ -258,7 +257,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
@@ -278,18 +277,18 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
@@ -297,8 +296,8 @@ mod tests {
fn from_draft_1080p_batch_4() {
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
assert!(matches!(cost.resolution, KinoviOutputResolution::TenEightyP));
assert!(matches!(cost.batch_count, KinoviBatchCount::Four));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
}
@@ -308,7 +307,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
@@ -323,8 +322,8 @@ mod tests {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(480));
}
}
@@ -383,10 +382,7 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_usd_cents
.unwrap()
@@ -397,10 +393,7 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_credits
.unwrap()
@@ -450,12 +443,13 @@ mod tests {
};
KinoviSeedance2p0RequestState {
request: GenerateSeedance2p0Request {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: "test".to_string(),
aspect_ratio: None,
aspect_ratio: KinoviAspectRatio::Landscape16x9,
output_resolution: resolution,
duration_seconds,
batch_count: Some(batch_count),
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -1,7 +1,4 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio,
KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
@@ -22,10 +19,10 @@ pub struct KinoviSeedance2p0DraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviSeedance2p0AspectRatio,
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0BatchCount,
pub batch_count: KinoviBatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
@@ -80,12 +77,13 @@ impl KinoviSeedance2p0DraftState {
)?;
}
let request = GenerateSeedance2p0Request {
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: self.prompt.clone(),
aspect_ratio: Some(self.aspect_ratio),
aspect_ratio: self.aspect_ratio,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
batch_count: self.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -1,31 +1,28 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
};
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};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0RequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSeedance2p0Request,
pub request: KinoviGenerateVideoRequest,
}
impl KinoviSeedance2p0RequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSeedance2p0Args {
let args = GenerateVideoArgs {
session,
host_override: None,
request: self.request.clone(),
request: self.request.clone(), // TODO: Yuck.
};
let response = generate_seedance_2p0(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
@@ -35,3 +32,320 @@ impl KinoviSeedance2p0RequestState {
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
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::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::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
const STEAMPUNK_CLOWN_KINOVI_ID: &str = "char_1775176566518_sik0te";
const MOCHI_KINOVI_ID: &str = "char_1775177718294_g2pitx";
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),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
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),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[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 whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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 start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn video_reference() {
// NB: This URL is a pre-uploaded video on the Seedance2Pro CDN from prior tests.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
reference_videos: Some(VideoListRef::Urls(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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 char_clown = CharacterToken::new("char_clown".to_string());
let char_mochi = CharacterToken::new("char_mochi".to_string());
let mut character_map = HashMap::new();
character_map.insert(char_clown.clone(), STEAMPUNK_CLOWN_KINOVI_ID.to_string());
character_map.insert(char_mochi.clone(), MOCHI_KINOVI_ID.to_string());
let response = run_pipeline_with_maps(
GenerateVideoRequestBuilder {
prompt: Some("@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
char_clown,
char_mochi,
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
},
HashMap::new(),
character_map,
).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// TODO: Add audio reference test once a web-accessible audio URL is available in test_data.
}
// ── Helpers ──
fn seedance2pro_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
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))
}
/// Run the full pipeline: builder → build2 → finalize → send.
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
}
/// Run the full pipeline with a media file token map for resolving references.
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
}
/// Run the full pipeline with both a media map and character map.
async fn run_pipeline_with_maps(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
character_map: HashMap<CharacterToken, 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),
character_token_to_kinovi_id_map: Some(&character_map),
};
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,8 +1,4 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
@@ -77,7 +73,6 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -100,6 +95,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
@@ -212,11 +208,7 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -1,23 +1,22 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount,
KinoviSeedance2p0FastOutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
pub struct KinoviSeedance2p0FastCostState {
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub resolution: KinoviOutputResolution,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p0FastBatchCount>,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub has_video_reference: bool,
}
impl KinoviSeedance2p0FastCostState {
pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self {
Self {
resolution: request.request.output_resolution,
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
@@ -33,22 +32,27 @@ impl KinoviSeedance2p0FastCostState {
.is_some();
Self {
resolution: draft.resolution,
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
batch_count: draft.batch_count,
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let pricing_request = GenerateSeedance2p0FastRequest {
output_resolution: self.resolution,
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Fast,
output_resolution: Some(self.resolution),
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: None,
aspect_ratio: KinoviAspectRatio::Portrait9x16,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -58,8 +62,8 @@ impl KinoviSeedance2p0FastCostState {
use_face_blur_hack: None,
};
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -76,11 +80,6 @@ impl KinoviSeedance2p0FastCostState {
mod tests {
use super::*;
use crate::api::common_resolution::CommonResolution;
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
};
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
@@ -91,51 +90,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (28 credits/sec, 193 credits/$1) --
// -- 720p (legacy pricing: 220 credits/$1, 28 credits/sec) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 51);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 127);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 191);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 127);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 255);
}
}
// -- 480p (14 credits/sec, 193 credits/$1) --
// -- 480p (new pricing: 193 credits/$1, 10 credits/sec) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 36);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 21);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 26);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 52);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 78);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 52);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 104);
}
}
@@ -175,9 +174,9 @@ mod tests {
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0FastCostState {
resolution: Some(KinoviOutputResolution::SevenTwentyP),
resolution: KinoviOutputResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: Some(KinoviBatchCount::One),
batch_count: KinoviBatchCount::One,
has_video_reference: false,
};
let without = base.estimate_cost();
@@ -195,26 +194,26 @@ mod tests {
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
}
#[test]
@@ -223,7 +222,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
@@ -243,19 +242,19 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.resolution.is_none());
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
}
#[test]
@@ -264,7 +263,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
@@ -279,8 +278,8 @@ mod tests {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(382));
}
}
@@ -323,7 +322,7 @@ mod tests {
#[test]
fn credits_480p() {
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 70);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 50);
}
}
@@ -334,10 +333,7 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_usd_cents
.unwrap()
@@ -348,10 +344,7 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_credits
.unwrap()
@@ -402,12 +395,13 @@ mod tests {
};
KinoviSeedance2p0FastRequestState {
request: GenerateSeedance2p0FastRequest {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: "test".to_string(),
aspect_ratio: None,
aspect_ratio: KinoviAspectRatio::Landscape16x9,
output_resolution: resolution,
duration_seconds,
batch_count: Some(batch_count),
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -1,7 +1,4 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio,
KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
@@ -19,12 +16,15 @@ use crate::generate::generate_video_v2::video_generation_draft_context::VideoGen
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastDraftState {
pub prompt: String,
pub aspect_ratio: KinoviSeedance2p0FastAspectRatio,
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0FastBatchCount,
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
@@ -77,12 +77,13 @@ impl KinoviSeedance2p0FastDraftState {
)?;
}
let request = GenerateSeedance2p0FastRequest {
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: self.prompt.clone(),
aspect_ratio: Some(self.aspect_ratio),
aspect_ratio: self.aspect_ratio,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
batch_count: self.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -1,31 +1,28 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
};
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};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastRequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSeedance2p0FastRequest,
pub request: KinoviGenerateVideoRequest,
}
impl KinoviSeedance2p0FastRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSeedance2p0FastArgs {
let args = GenerateVideoArgs {
session,
host_override: None,
request: self.request.clone(),
request: self.request.clone(), // TODO: Yuck.
};
let response = generate_seedance_2p0_fast(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
@@ -35,3 +32,229 @@ impl KinoviSeedance2p0FastRequestState {
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
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::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::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
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),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
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),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[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 whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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 start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
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::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
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))
}
/// Run the full pipeline: builder -> build2 -> finalize -> send.
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
}
/// Run the full pipeline with a media file token map for resolving references.
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
}
}
@@ -38,7 +38,6 @@ pub struct GenerateSeedance2p0Request {
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0AspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
@@ -156,7 +155,6 @@ pub async fn generate_seedance_2p0(
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0AspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
@@ -432,7 +430,6 @@ mod tests {
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
KinoviSeedance2p0AspectRatio::UltraWide21x9,
KinoviSeedance2p0AspectRatio::Portrait9x16,
KinoviSeedance2p0AspectRatio::Square1x1,
KinoviSeedance2p0AspectRatio::Standard4x3,
@@ -531,70 +528,6 @@ mod tests {
}
}
mod ultra_wide {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_21x9() -> 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 is riding on the back of a sauropod dinosaur".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9),
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 21:9 — 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_21x9() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "The dogs sail across the ocean on a treasure ship.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9),
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 21:9 — 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::*;
@@ -38,7 +38,6 @@ pub struct GenerateSeedance2p0FastRequest {
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0FastAspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
@@ -153,7 +152,6 @@ pub async fn generate_seedance_2p0_fast(
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0FastAspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0FastAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0FastAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
@@ -384,7 +382,6 @@ mod tests {
let ratios = [
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
KinoviSeedance2p0FastAspectRatio::UltraWide21x9,
KinoviSeedance2p0FastAspectRatio::Portrait9x16,
KinoviSeedance2p0FastAspectRatio::Square1x1,
KinoviSeedance2p0FastAspectRatio::Standard4x3,
@@ -483,70 +480,6 @@ mod tests {
}
}
mod ultra_wide {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_21x9() -> 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 shiba is riding on the back of a sauropod dinosaur".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9),
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!("fast t2v 21:9 480p — 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_21x9() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "The dogs in @1 set sail across the ocean on a treasure ship.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9),
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![start_frame_url]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("fast keyframe 21:9 — 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::*;
@@ -172,8 +172,6 @@ impl KinoviGenerateVideoRequest {
pub enum KinoviAspectRatio {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 21:9 ultra-wide (1280x540)
UltraWide21x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
@@ -188,7 +186,6 @@ impl KinoviAspectRatio {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::UltraWide21x9 => "1280x540",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Standard4x3 => "960x720",
@@ -181,8 +181,6 @@ impl WorkflowRunTaskRequest {
pub enum KinoviAspectRatioRaw {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 21:9 ultra-wide (1280x540)
UltraWide21x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
@@ -197,7 +195,6 @@ impl KinoviAspectRatioRaw {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::UltraWide21x9 => "1280x540",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Landscape4x3 => "960x720",
@@ -1,58 +0,0 @@
# support-tool
Production support CLI for debugging and manual operations against external services.
## Building & Running
```bash
cargo build -p support-tool
cargo run -p support-tool -- seedance2pro generate_video --prompt "A corgi at the lake"
```
## Environment
Requires a `.env-support-tool-secrets` file (or env vars) with:
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/Seedance2Pro API auth
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
## Architecture
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
- Top-level commands in `src/commands/run.rs` (`TopLevelCommand` enum)
- Each top-level command has its own module with `dispatch.rs`, `state.rs`, `subcommands/`
- Subcommands use `#[derive(Args)]` for their argument structs
- All subcommand `run()` functions are `async fn` returning `anyhow::Result<()>`
## Adding a New Subcommand (to seedance2pro)
1. Create `src/commands/seedance2pro/subcommands/my_command.rs`
2. Add `pub mod my_command;` to `subcommands/mod.rs`
3. Add the name to `SUBCOMMAND_NAMES` in `dispatch.rs`
4. Add a variant to `Seedance2proCommand` enum in `dispatch.rs`
5. Add a match arm in `dispatch.rs::run()`
## Utilities
- `src/utils/parse_video_model.rs` — shared parser for loose video model names → `CommonVideoModel`
- `src/utils/normalize_subcommands.rs` — underscore-insensitive arg normalizer
## Conventions
- Use `log::info!()` for status output, never `println!` (except for final results)
- Use `anyhow::anyhow!()` for ad-hoc errors
- Get `Seedance2ProSession` from `state.cookies` via `from_cookies_string()`
- Get `StorytellerCredentialSet` from `state.creds` (parsed from ARTCRAFT_COOKIES)
- External crates used: `artcraft_client` (media file download, omni API), `seedance2pro_client` (Kinovi direct), `artcraft_api_defs` (request types)
## Subcommands
### seedance2pro (direct Kinovi API)
- `seedance2pro find_job --token <order_id>` — search for a job across all pages
- `seedance2pro failed_job_histogram` — histogram of failure reasons
- `seedance2pro generate_video --prompt <text_or_file> [--model seedance2p0] [--start_frame_media_token mf_xxx] [--localhost] [--download_path /tmp/media_files]`
### artcraft (omni API)
- `artcraft generate_video --prompt <text_or_file> [--model seedance2p0] [--start_frame_media_token mf_xxx] [--duration 5] [--localhost]`
@@ -11,12 +11,8 @@ path = "src/main.rs"
easyenv.workspace = true
# Internal
artcraft_api_defs.workspace = true
artcraft_client.workspace = true
enums.workspace = true
errors.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
@@ -24,4 +20,3 @@ clap.workspace = true
log.workspace = true
serde_json.workspace = true
tokio.workspace = true
uuid = { version = "1.8.0", features = ["v4"] }
@@ -1,33 +0,0 @@
use anyhow::anyhow;
use clap::Subcommand;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use super::state::ArtcraftState;
use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["generate_video"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum ArtcraftCommand {
/// Generate a video via the ArtCraft omni endpoint
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: ArtcraftCommand) -> anyhow::Result<()> {
let cookies_str = easyenv::get_env_string_required("ARTCRAFT_COOKIES")
.map_err(|err| anyhow!("Missing ARTCRAFT_COOKIES env var: {:?}", err))?;
let creds = StorytellerCredentialSet::parse_multi_cookie_header(&cookies_str)
.map_err(|err| anyhow!("Failed to parse ARTCRAFT_COOKIES: {:?}", err))?
.ok_or_else(|| anyhow!("ARTCRAFT_COOKIES contained no recognized cookies (expected session=... and/or visitor=...)"))?;
let state = ArtcraftState { creds };
match command {
ArtcraftCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
@@ -1,6 +0,0 @@
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::ArtcraftCommand;
pub use dispatch::run;
@@ -1,5 +0,0 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub struct ArtcraftState {
pub creds: StorytellerCredentialSet,
}
@@ -1,179 +0,0 @@
use std::fs;
use std::path::Path;
use anyhow::anyhow;
use clap::Args;
use log::info;
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 artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::utils::parse_video_model::parse_video_model;
use super::super::state::ArtcraftState;
// ── Args ──
#[derive(Args)]
#[command(
override_usage = "support-tool artcraft generate_video [OPTIONS] --prompt <PROMPT>",
after_help = "\
EXAMPLES:
support-tool artcraft generate_video --prompt \"A corgi at the lake\" --model seedance2
support-tool artcraft generate_video --prompt prompt.txt --model veo3
support-tool artcraft generate_video --prompt \"Dancing\" --start_frame_media_token mf_abc123
support-tool artcraft generate_video --prompt \"Cat\" --image_reference_tokens \"mf_abc,mf_def\" --model happyhorse
",
)]
pub struct GenerateVideoArgs {
/// A prompt string or path to a file containing the prompt (.txt or .md).
#[arg(long)]
pub prompt: String,
/// A single media token for the start frame image.
#[arg(long)]
pub start_frame_media_token: Option<String>,
/// A single media token for the end frame image.
#[arg(long)]
pub end_frame_media_token: Option<String>,
/// Comma or space separated media tokens for image references.
#[arg(long)]
pub image_reference_tokens: Option<String>,
/// Comma or space separated media tokens for video references.
#[arg(long)]
pub video_reference_tokens: Option<String>,
/// Comma or space separated media tokens for audio references.
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// Use localhost:12345 instead of production API.
#[arg(long)]
pub localhost: bool,
/// Video model to use. Accepts canonical names (eg. "seedance_2p0") or aliases
/// (eg. "seedance2", "happyhorse", "veo3fast"). [default: seedance2p0]
#[arg(long)]
pub model: Option<String>,
/// Duration in seconds. [default: 5]
#[arg(long)]
pub duration: Option<u16>,
/// Idempotency token (auto-generated if not specified).
#[arg(long)]
pub idempotency_token: Option<String>,
}
// ── Entry point ──
pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Result<()> {
let model_str = args.model.as_deref().unwrap_or("seedance2p0");
let model = parse_video_model(model_str)
.ok_or_else(|| anyhow!("Unknown model '{}'. Try --help for supported names.", model_str))?;
let prompt = resolve_prompt(&args.prompt)?;
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
let duration = args.duration.unwrap_or(5);
info!("Model: {:?}", model);
info!("Prompt: {:?}", prompt);
info!("Duration: {}s", duration);
// Parse media tokens.
let start_frame_token = args.start_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let end_frame_token = args.end_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let image_ref_tokens = parse_media_tokens(args.image_reference_tokens.as_deref());
let video_ref_tokens = parse_media_tokens(args.video_reference_tokens.as_deref());
let audio_ref_tokens = parse_media_tokens(args.audio_reference_tokens.as_deref());
if let Some(t) = &start_frame_token { info!("Start frame: {}", t.as_str()); }
if let Some(t) = &end_frame_token { info!("End frame: {}", t.as_str()); }
if !image_ref_tokens.is_empty() { info!("Image refs: {:?}", image_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
if !video_ref_tokens.is_empty() { info!("Video refs: {:?}", video_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
if !audio_ref_tokens.is_empty() { info!("Audio refs: {:?}", audio_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
// Build the omni request. Media tokens are already uploaded — no download/re-upload needed.
let idempotency_token = args.idempotency_token
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(idempotency_token.clone()),
model: Some(model),
prompt: Some(prompt),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: end_frame_token,
reference_image_media_tokens: if image_ref_tokens.is_empty() { None } else { Some(image_ref_tokens) },
reference_video_media_tokens: if video_ref_tokens.is_empty() { None } else { Some(video_ref_tokens) },
reference_audio_media_tokens: if audio_ref_tokens.is_empty() { None } else { Some(audio_ref_tokens) },
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: Some(duration),
video_batch_count: None,
generate_audio: None,
};
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
let response = omni_gen_video_generate(
&api_host,
Some(&state.creds),
request,
).await.map_err(|err| anyhow!("Omni video generation failed: {}", err))?;
info!("Success!");
info!("Primary job token: {}", response.inference_job_token.as_str());
info!("All job tokens: {:?}", response.all_job_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>());
Ok(())
}
// ── Helpers ──
fn resolve_prompt(input: &str) -> anyhow::Result<String> {
let looks_like_file = input.ends_with(".txt") || input.ends_with(".md");
if looks_like_file {
let path = Path::new(input);
if !path.exists() {
return Err(anyhow!("Prompt file does not exist: {:?}", path));
}
let content = fs::read_to_string(path)
.map_err(|err| anyhow!("Failed to read prompt file {:?}: {}", path, err))?;
let trimmed = content.trim().to_string();
info!("Read prompt from file {:?} ({} chars)", path, trimmed.len());
Ok(trimmed)
} else {
info!("Using prompt string directly ({} chars)", input.len());
Ok(input.to_string())
}
}
fn parse_media_tokens(input: Option<&str>) -> Vec<MediaFileToken> {
let input = match input {
Some(s) if !s.trim().is_empty() => s,
_ => return Vec::new(),
};
input
.split(|c: char| c == ',' || c.is_whitespace())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s))
.collect()
}
@@ -1 +0,0 @@
pub mod generate_video;
@@ -1,3 +1,2 @@
pub mod artcraft;
pub mod run;
pub mod seedance2pro;
@@ -1,14 +1,12 @@
use clap::{Parser, Subcommand};
use super::artcraft;
use super::seedance2pro;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["seedance2pro", "artcraft"];
let mut names: Vec<&str> = vec!["seedance2pro"];
names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES);
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
names
}
@@ -22,17 +20,11 @@ pub struct Cli {
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands (direct Kinovi API)
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
/// ArtCraft support commands (omni API)
Artcraft {
#[command(subcommand)]
command: artcraft::ArtcraftCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
@@ -40,8 +32,5 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
TopLevelCommand::Artcraft { command } => {
artcraft::run(command).await
}
}
}
@@ -6,11 +6,7 @@ use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &[
"failed_job_histogram",
"find_job",
"generate_video",
];
pub const SUBCOMMAND_NAMES: &[&str] = &["find_job", "failed_job_histogram"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
@@ -20,9 +16,6 @@ pub enum Seedance2proCommand {
/// Scan all jobs and print a histogram of failure reasons
FailedJobHistogram,
/// Generate a video via Seedance2Pro/Kinovi directly
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
@@ -34,6 +27,5 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
match command {
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
@@ -1,475 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::anyhow;
use clap::Args;
use log::info;
use artcraft_client::recipes::download_media_file::{
download_media_file, DownloadMediaFileArgs, DownloadPath,
};
use artcraft_client::utils::api_host::ApiHost;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
generate_happy_horse_1p0, GenerateHappyHorse1p0Args, GenerateHappyHorse1p0Request,
};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use tokens::tokens::media_files::MediaFileToken;
use super::super::state::Seedance2ProState;
const DEFAULT_DOWNLOAD_PATH: &str = "/tmp/media_files";
// ── Args ──
#[derive(Args)]
#[command(
override_usage = "support-tool seedance2pro generate_video [OPTIONS] --prompt <PROMPT>",
after_help = "\
EXAMPLES:
support-tool seedance2pro generate_video --prompt \"A corgi at the lake\"
support-tool seedance2pro generate_video --prompt prompt.txt --model happyhorse
support-tool seedance2pro generate_video --prompt \"Dancing\" --start_frame_media_token mf_abc123
support-tool seedance2pro generate_video --prompt \"Cat\" --image_reference_tokens \"mf_abc,mf_def\"
",
)]
pub struct GenerateVideoArgs {
/// A prompt string or path to a file containing the prompt (.txt or .md).
#[arg(long)]
pub prompt: String,
/// A single media token for the start frame image.
#[arg(long)]
pub start_frame_media_token: Option<String>,
/// A single media token for the end frame image.
#[arg(long)]
pub end_frame_media_token: Option<String>,
/// Comma or space separated media tokens for image references.
#[arg(long)]
pub image_reference_tokens: Option<String>,
/// Comma or space separated media tokens for video references.
#[arg(long)]
pub video_reference_tokens: Option<String>,
/// Comma or space separated media tokens for audio references.
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// Use localhost:12345 instead of production API for artcraft_client lookups.
#[arg(long)]
pub localhost: bool,
/// Directory to cache downloaded media files. [default: /tmp/media_files]
#[arg(long)]
pub download_path: Option<String>,
/// Model: seedance2p0, seedance2p0fast, happyhorse (and underscore variants).
/// [default: seedance2p0]
#[arg(long)]
pub model: Option<String>,
}
// ── Model enum ──
#[derive(Debug, Clone, Copy)]
enum VideoModel {
Seedance2p0,
Seedance2p0Fast,
HappyHorse,
}
fn parse_model(value: &str) -> anyhow::Result<VideoModel> {
match value.to_lowercase().as_str() {
"seedance2" | "seedance2p0" | "seedance_2p0" => Ok(VideoModel::Seedance2p0),
"seedance2fast" | "seedance2p0fast" | "seedance_2p0_fast" => Ok(VideoModel::Seedance2p0Fast),
"happyhorse" | "happy_horse" => Ok(VideoModel::HappyHorse),
other => Err(anyhow!(
"Unknown model '{}'. Valid: seedance2, seedance2p0, seedance_2p0, \
seedance2fast, seedance2p0fast, seedance_2p0_fast, happyhorse, happy_horse",
other
)),
}
}
// ── Resolved media inputs ──
struct ResolvedMediaInputs {
start_frame_url: Option<String>,
end_frame_url: Option<String>,
reference_image_urls: Option<Vec<String>>,
reference_video_urls: Option<Vec<String>>,
reference_audio_urls: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn run(state: &Seedance2ProState, args: GenerateVideoArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let model = parse_model(args.model.as_deref().unwrap_or("seedance2p0"))?;
let prompt = resolve_prompt(&args.prompt)?;
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
info!("Model: {:?}", model);
info!("Prompt: {:?}", prompt);
// Resolve download path.
let download_dir = resolve_download_dir(args.download_path.as_deref())?;
info!("Download cache directory: {:?}", download_dir);
// Parse media tokens.
let start_frame_token = args.start_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let end_frame_token = args.end_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let image_ref_tokens = parse_media_tokens(args.image_reference_tokens.as_deref());
let video_ref_tokens = parse_media_tokens(args.video_reference_tokens.as_deref());
let audio_ref_tokens = parse_media_tokens(args.audio_reference_tokens.as_deref());
// Collect all tokens that need downloading.
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
if let Some(t) = &start_frame_token { all_tokens.push(t.clone()); }
if let Some(t) = &end_frame_token { all_tokens.push(t.clone()); }
all_tokens.extend(image_ref_tokens.iter().cloned());
all_tokens.extend(video_ref_tokens.iter().cloned());
all_tokens.extend(audio_ref_tokens.iter().cloned());
info!("Total media tokens to process: {}", all_tokens.len());
// Download media files (with caching).
let downloaded_files = download_media_files(&all_tokens, &api_host, &download_dir).await?;
// Upload media files to Kinovi/Seedance2Pro.
let uploaded_urls = upload_media_files_to_kinovi(&session, &downloaded_files).await?;
// Map uploaded URLs back to their roles.
let mut url_iter = uploaded_urls.into_iter();
let start_frame_url = if start_frame_token.is_some() { url_iter.next() } else { None };
let end_frame_url = if end_frame_token.is_some() { url_iter.next() } else { None };
let reference_image_urls: Vec<String> = url_iter.by_ref().take(image_ref_tokens.len()).collect();
let reference_video_urls: Vec<String> = url_iter.by_ref().take(video_ref_tokens.len()).collect();
let reference_audio_urls: Vec<String> = url_iter.by_ref().take(audio_ref_tokens.len()).collect();
let media_inputs = ResolvedMediaInputs {
start_frame_url,
end_frame_url,
reference_image_urls: if reference_image_urls.is_empty() { None } else { Some(reference_image_urls) },
reference_video_urls: if reference_video_urls.is_empty() { None } else { Some(reference_video_urls) },
reference_audio_urls: if reference_audio_urls.is_empty() { None } else { Some(reference_audio_urls) },
};
// Generate video.
generate(model, &session, &prompt, media_inputs).await
}
// ── Prompt resolution ──
fn resolve_prompt(input: &str) -> anyhow::Result<String> {
let looks_like_file = input.ends_with(".txt") || input.ends_with(".md");
if looks_like_file {
let path = Path::new(input);
if !path.exists() {
return Err(anyhow!("Prompt file does not exist: {:?}", path));
}
let content = fs::read_to_string(path)
.map_err(|err| anyhow!("Failed to read prompt file {:?}: {}", path, err))?;
let trimmed = content.trim().to_string();
info!("Read prompt from file {:?} ({} chars)", path, trimmed.len());
Ok(trimmed)
} else {
info!("Using prompt string directly ({} chars)", input.len());
Ok(input.to_string())
}
}
// ── Download path resolution ──
fn resolve_download_dir(maybe_path: Option<&str>) -> anyhow::Result<PathBuf> {
let dir = match maybe_path {
Some(path) => {
let p = PathBuf::from(path);
if p.exists() && !p.is_dir() {
return Err(anyhow!("--download_path {:?} exists but is not a directory", p));
}
p
}
None => PathBuf::from(DEFAULT_DOWNLOAD_PATH),
};
if !dir.exists() {
info!("Creating download directory: {:?}", dir);
fs::create_dir_all(&dir)
.map_err(|err| anyhow!("Failed to create download directory {:?}: {}", dir, err))?;
}
Ok(dir)
}
// ── Media token parsing ──
fn parse_media_tokens(input: Option<&str>) -> Vec<MediaFileToken> {
let input = match input {
Some(s) if !s.trim().is_empty() => s,
_ => return Vec::new(),
};
input
.split(|c: char| c == ',' || c.is_whitespace())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s))
.collect()
}
// ── Download media files with caching ──
struct DownloadedFile {
path: PathBuf,
extension: String,
}
async fn download_media_files(
tokens: &[MediaFileToken],
api_host: &ApiHost,
download_dir: &Path,
) -> anyhow::Result<Vec<DownloadedFile>> {
let mut results = Vec::new();
for token in tokens {
// Check if file already exists in cache.
let maybe_cached = find_cached_file(download_dir, token);
if let Some(cached) = maybe_cached {
info!(
"Cache hit: {} already exists at {:?} ({} bytes)",
token.as_str(),
cached.path,
fs::metadata(&cached.path).map(|m| m.len()).unwrap_or(0),
);
results.push(cached);
continue;
}
info!("Downloading {} from API...", token.as_str());
let result = download_media_file(DownloadMediaFileArgs {
media_token: token,
api_host,
download_path: DownloadPath::Directory(download_dir),
}).await.map_err(|err| anyhow!("Failed to download {}: {}", token.as_str(), err))?;
let extension = result.downloaded_file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_string();
info!(
"Downloaded: {} -> {:?} ({} bytes, class: {})",
token.as_str(),
result.downloaded_file_path,
result.filesize_bytes,
result.media_file_response.media_file.media_class.to_str(),
);
results.push(DownloadedFile {
path: result.downloaded_file_path,
extension,
});
}
Ok(results)
}
/// Look for an existing cached file matching the token (any extension).
fn find_cached_file(download_dir: &Path, token: &MediaFileToken) -> Option<DownloadedFile> {
let prefix = format!("{}.", token.as_str());
let entries = fs::read_dir(download_dir).ok()?;
for entry in entries.flatten() {
let filename = entry.file_name();
let filename_str = filename.to_string_lossy();
if filename_str.starts_with(&prefix) {
let path = entry.path();
// Skip zero-byte files — treat as incomplete downloads.
let metadata = fs::metadata(&path).ok()?;
if metadata.len() == 0 {
continue;
}
let extension = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_string();
return Some(DownloadedFile { path, extension });
}
}
None
}
// ── Upload to Kinovi/Seedance2Pro ──
async fn upload_media_files_to_kinovi(
session: &Seedance2ProSession,
files: &[DownloadedFile],
) -> anyhow::Result<Vec<String>> {
let mut urls = Vec::new();
for file in files {
info!("Uploading {:?} to Kinovi (extension: {})...", file.path, file.extension);
// 1. Prepare upload (get signed URL).
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: file.extension.clone(),
host_override: None,
}).await.map_err(|err| anyhow!("Failed to prepare upload for {:?}: {:?}", file.path, err))?;
// 2. Read file bytes.
let file_bytes = fs::read(&file.path)
.map_err(|err| anyhow!("Failed to read {:?}: {}", file.path, err))?;
info!("Uploading {} bytes to signed URL...", file_bytes.len());
// 3. Upload to Cloudflare R2.
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
host_override: None,
}).await.map_err(|err| anyhow!("Failed to upload {:?}: {:?}", file.path, err))?;
info!("Uploaded: {:?} -> {}", file.path, upload_result.public_url);
urls.push(upload_result.public_url);
}
Ok(urls)
}
// ── Generate video ──
async fn generate(
model: VideoModel,
session: &Seedance2ProSession,
prompt: &str,
media: ResolvedMediaInputs,
) -> anyhow::Result<()> {
match model {
VideoModel::HappyHorse => {
// Happy Horse only supports start_frame_url.
if media.end_frame_url.is_some() {
return Err(anyhow!("Happy Horse does not support --end_frame_media_token"));
}
if media.reference_image_urls.is_some() {
return Err(anyhow!("Happy Horse does not support --image_reference_tokens"));
}
if media.reference_video_urls.is_some() {
return Err(anyhow!("Happy Horse does not support --video_reference_tokens"));
}
if media.reference_audio_urls.is_some() {
return Err(anyhow!("Happy Horse does not support --audio_reference_tokens"));
}
info!("Generating video with Happy Horse 1.0...");
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
request: GenerateHappyHorse1p0Request {
prompt: prompt.to_string(),
aspect_ratio: None,
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: media.start_frame_url,
},
session,
host_override: None,
}).await.map_err(|err| anyhow!("Happy Horse generation failed: {:?}", err))?;
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
if let Some(task_ids) = &result.task_ids {
info!("All task_ids: {:?}", task_ids);
}
if let Some(order_ids) = &result.order_ids {
info!("All order_ids: {:?}", order_ids);
}
}
VideoModel::Seedance2p0 => {
info!("Generating video with Seedance 2.0 Pro...");
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
request: GenerateSeedance2p0Request {
prompt: prompt.to_string(),
aspect_ratio: None,
output_resolution: None,
duration_seconds: 5,
batch_count: None,
start_frame_url: media.start_frame_url,
end_frame_url: media.end_frame_url,
reference_image_urls: media.reference_image_urls,
reference_video_urls: media.reference_video_urls,
reference_audio_urls: media.reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
session,
host_override: None,
}).await.map_err(|err| anyhow!("Seedance 2.0 generation failed: {:?}", err))?;
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
if let Some(task_ids) = &result.task_ids {
info!("All task_ids: {:?}", task_ids);
}
if let Some(order_ids) = &result.order_ids {
info!("All order_ids: {:?}", order_ids);
}
}
VideoModel::Seedance2p0Fast => {
info!("Generating video with Seedance 2.0 Fast...");
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
request: GenerateSeedance2p0FastRequest {
prompt: prompt.to_string(),
aspect_ratio: None,
output_resolution: None,
duration_seconds: 5,
batch_count: None,
start_frame_url: media.start_frame_url,
end_frame_url: media.end_frame_url,
reference_image_urls: media.reference_image_urls,
reference_video_urls: media.reference_video_urls,
reference_audio_urls: media.reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
session,
host_override: None,
}).await.map_err(|err| anyhow!("Seedance 2.0 Fast generation failed: {:?}", err))?;
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
if let Some(task_ids) = &result.task_ids {
info!("All task_ids: {:?}", task_ids);
}
if let Some(order_ids) = &result.order_ids {
info!("All order_ids: {:?}", order_ids);
}
}
}
Ok(())
}
@@ -1,3 +1,2 @@
pub mod failed_job_histogram;
pub mod find_job;
pub mod generate_video;
@@ -1,2 +1 @@
pub mod normalize_subcommands;
pub mod parse_video_model;
@@ -1,83 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel;
/// Parse a loose model name into a CommonVideoModel.
///
/// Accepts the canonical serde names (eg. "seedance_2p0") as well as
/// human-friendly aliases (eg. "seedance2", "happyhorse", "veo3fast").
pub fn parse_video_model(value: &str) -> Option<CommonVideoModel> {
let value = value.trim();
// Try canonical serde deserialization first (eg. "seedance_2p0", "happy_horse_1p0").
if let Ok(model) = serde_json::from_str::<CommonVideoModel>(&format!("\"{}\"", value)) {
return Some(model);
}
// Fallback: human-friendly aliases.
match value.to_lowercase().replace('-', "_").as_str() {
// Seedance family
"seedance2" | "seedance2p0" | "seedance_2p0" => Some(CommonVideoModel::Seedance2p0),
"seedance2fast" | "seedance2p0fast" | "seedance_2p0_fast" => Some(CommonVideoModel::Seedance2p0Fast),
"seedance1" | "seedance10lite" | "seedance_1p0_lite" => Some(CommonVideoModel::Seedance10Lite),
"seedance15" | "seedance1p5" | "seedance_1p5_pro" => Some(CommonVideoModel::Seedance1p5Pro),
// Happy Horse
"happyhorse" | "happy_horse" | "happyhorse1" | "happy_horse_1p0" => Some(CommonVideoModel::HappyHorse1p0),
// Kling family
"kling16" | "kling_1p6_pro" => Some(CommonVideoModel::Kling16Pro),
"kling21" | "kling_2p1_pro" => Some(CommonVideoModel::Kling21Pro),
"kling21master" | "kling_2p1_master" => Some(CommonVideoModel::Kling21Master),
"kling25turbo" | "kling_2p5_turbo_pro" => Some(CommonVideoModel::Kling2p5TurboPro),
"kling26" | "kling_2p6_pro" => Some(CommonVideoModel::Kling2p6Pro),
"kling3" | "kling30" | "kling_3p0_standard" => Some(CommonVideoModel::Kling3p0Standard),
"kling3pro" | "kling30pro" | "kling_3p0_pro" => Some(CommonVideoModel::Kling3p0Pro),
// Veo family
"veo2" | "veo_2" => Some(CommonVideoModel::Veo2),
"veo3" | "veo_3" => Some(CommonVideoModel::Veo3),
"veo3fast" | "veo_3_fast" => Some(CommonVideoModel::Veo3Fast),
"veo31" | "veo3p1" | "veo_3p1" => Some(CommonVideoModel::Veo3p1),
"veo31fast" | "veo3p1fast" | "veo_3p1_fast" => Some(CommonVideoModel::Veo3p1Fast),
// Sora family
"sora2" | "sora_2" => Some(CommonVideoModel::Sora2),
"sora2pro" | "sora_2_pro" => Some(CommonVideoModel::Sora2Pro),
// Grok
"grok" | "grok_video" | "grokvideo" => Some(CommonVideoModel::GrokVideo),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_names() {
assert_eq!(parse_video_model("seedance_2p0"), Some(CommonVideoModel::Seedance2p0));
assert_eq!(parse_video_model("happy_horse_1p0"), Some(CommonVideoModel::HappyHorse1p0));
assert_eq!(parse_video_model("veo_3"), Some(CommonVideoModel::Veo3));
}
#[test]
fn aliases() {
assert_eq!(parse_video_model("seedance2"), Some(CommonVideoModel::Seedance2p0));
assert_eq!(parse_video_model("seedance2fast"), Some(CommonVideoModel::Seedance2p0Fast));
assert_eq!(parse_video_model("happyhorse"), Some(CommonVideoModel::HappyHorse1p0));
assert_eq!(parse_video_model("veo3fast"), Some(CommonVideoModel::Veo3Fast));
assert_eq!(parse_video_model("kling3pro"), Some(CommonVideoModel::Kling3p0Pro));
}
#[test]
fn unknown() {
assert_eq!(parse_video_model("nonexistent"), None);
assert_eq!(parse_video_model(""), None);
}
#[test]
fn trims_whitespace() {
assert_eq!(parse_video_model(" seedance2 "), Some(CommonVideoModel::Seedance2p0));
}
}
@@ -37,20 +37,16 @@ pub async fn handle_video_artcraft(
None => {
Err(GenerateError::no_model_specified())
},
// Handled via router
Some(VideoModel::Kling3p0Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro).await,
Some(VideoModel::Kling3p0Standard) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard).await,
Some(VideoModel::Seedance1p5Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro).await,
Some(VideoModel::Seedance2p0) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance2p0, GenerationModel::Seedance2p0).await,
Some(VideoModel::Seedance2p0Fast) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance2p0Fast, GenerationModel::Seedance2p0Fast).await,
Some(VideoModel::HappyHorse1p0) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::HappyHorse1p0, GenerationModel::HappyHorse1p0).await,
// Legacy endpoints
Some(VideoModel::Kling16Pro) => handle_artcraft_kling_1p6_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling21Pro) => handle_artcraft_kling_2p1_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling21Master) => handle_artcraft_kling_2p1_master(request, app_env_configs, &creds).await,
Some(VideoModel::Kling2p5TurboPro) => handle_artcraft_kling_2p5_turbo_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling2p6Pro) => handle_artcraft_kling_2p6_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling3p0Standard) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard).await,
Some(VideoModel::Kling3p0Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro).await,
Some(VideoModel::Seedance10Lite) => handle_artcraft_seedance_1_lite(request, app_env_configs, &creds).await,
Some(VideoModel::Seedance1p5Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro).await,
Some(VideoModel::Seedance2p0) => handle_artcraft_seedance_2p0(request, app_env_configs, &creds).await,
Some(VideoModel::Sora2) => handle_artcraft_sora2(request, app_env_configs, &creds).await,
Some(VideoModel::Sora2Pro) => handle_artcraft_sora2_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Veo2) => handle_artcraft_veo2(request, app_env_configs, &creds).await,
@@ -3,23 +3,18 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_video_model::CommonVideoModel;
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::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use artcraft_router::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::{error, info};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub(super) async fn handle_artcraft_video_via_router(
request: &EnqueueImageToVideoRequest,
@@ -36,43 +31,44 @@ pub(super) async fn handle_artcraft_video_via_router(
let start_frame = request.image_media_token.clone().map(ImageRef::MediaFileToken);
let end_frame = request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken);
let reference_images = request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens);
let reference_videos = request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens);
let reference_audio = request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens);
let reference_character_tokens = request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens);
let router_request = GenerateVideoRequestBuilder {
model,
provider: Provider::Artcraft,
prompt: request.prompt.clone(),
negative_prompt: None,
start_frame,
end_frame,
reference_images,
reference_videos,
reference_audio,
reference_character_tokens,
resolution: request.resolution,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens),
resolution: None,
aspect_ratio: request.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: None,
negative_prompt: None,
};
let response = if router_request.use_new_builder() {
info!("Building request for artcraft_router (v2 pipeline)...");
generate_via_v2(router_request, &client).await?
} else {
info!("Building request for artcraft_router (v1 pipeline)...");
generate_via_v1(router_request, &client).await?
let plan = router_request.build()?;
info!("Video Generation Plan: {:?}", plan);
let response = match plan.generate_video(&client).await {
Ok(resp) => {
info!("Successfully enqueued.");
resp
}
Err(err) => {
error!("Failed to enqueue: {:?}", err);
return Err(GenerateError::from(err));
}
};
let job_id = response.get_artcraft_payload()
.map(|p| p.inference_job_token.to_string())
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
.map(|p| p.inference_job_token.to_string())
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
Ok(TaskEnqueueSuccess {
task_type: TaskType::VideoGeneration,
@@ -81,43 +77,3 @@ pub(super) async fn handle_artcraft_video_via_router(
provider_job_id: Some(job_id),
})
}
/// V1 pipeline: build → plan → generate_video.
async fn generate_via_v1(
router_request: GenerateVideoRequestBuilder,
client: &RouterClient,
) -> Result<GenerateVideoResponse, GenerateError> {
let plan = router_request.build()?;
let response = plan.generate_video(client).await.map_err(|err| {
error!("V1 failed to enqueue: {:?}", err);
GenerateError::from(err)
})?;
info!("V1 successfully enqueued.");
Ok(response)
}
/// V2 pipeline: build2 → send_request (Artcraft skips draft phase).
async fn generate_via_v2(
router_request: GenerateVideoRequestBuilder,
client: &RouterClient,
) -> Result<GenerateVideoResponse, GenerateError> {
let draft_or_request = router_request.build2()?;
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
VideoGenerationDraftOrRequest::Draft(_) => {
error!("Unexpected Draft variant for Artcraft provider");
return Err(GenerateError::NotYetImplemented("Artcraft provider should not produce a draft request".to_string()));
}
};
let response = request.send_request(client).await.map_err(|err| {
error!("V2 failed to enqueue: {:?}", err);
GenerateError::from(err)
})?;
info!("V2 successfully enqueued.");
Ok(response)
}
@@ -1,14 +1,17 @@
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_video::handle_video_artcraft;
use crate::core::commands::enqueue::image_to_video::grok::handle_grok_video::handle_grok_video;
use crate::core::commands::enqueue::image_to_video::sora2::handle_sora_sora2::handle_sora_sora2;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::TextToImageModel;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
use crate::core::events::generation_events::common::GenerationAction;
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
@@ -21,7 +24,6 @@ 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_router::api::common_aspect_ratio::CommonAspectRatio;
use artcraft_router::api::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
@@ -59,9 +61,6 @@ pub enum VideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
@@ -70,9 +69,6 @@ pub enum VideoModel {
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
@@ -145,10 +141,6 @@ pub struct EnqueueImageToVideoRequest {
/// Aspect ratio for the generated video.
pub aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL.
/// Resolution for the generated video.
pub resolution: Option<CommonResolution>,
/// OPTIONAL.
/// Duration of the generated video in seconds.
pub duration_seconds: Option<u16>,
@@ -88,11 +88,9 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::Kling2p6Pro) => Some(TaskModelType::Kling2p6Pro),
Some(GenerationModel::Kling3p0Standard) => Some(TaskModelType::Kling3p0Standard),
Some(GenerationModel::Kling3p0Pro) => Some(TaskModelType::Kling3p0Pro),
Some(GenerationModel::HappyHorse1p0) => Some(TaskModelType::HappyHorse1p0),
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
Some(GenerationModel::Seedance1p5Pro) => Some(TaskModelType::Seedance1p5Pro),
Some(GenerationModel::Seedance2p0) => Some(TaskModelType::Seedance2p0),
Some(GenerationModel::Seedance2p0Fast) => Some(TaskModelType::Seedance2p0Fast),
Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2),
Some(GenerationModel::Sora2Pro) => Some(TaskModelType::Sora2Pro),
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
@@ -95,8 +95,6 @@ pub enum GenerationModel {
Kling3p0Standard,
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "recraft_3")]
Recraft3,
#[serde(rename = "sora")]
@@ -111,8 +109,6 @@ pub enum GenerationModel {
Seedance1p5Pro,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
+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.31.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.31.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.31.0",
"version": "0.30.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -1,83 +0,0 @@
use std::marker::PhantomData;
use log::warn;
use sqlx::{Executor, MySql, QueryBuilder};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
pub struct BulkInsertDebugLogsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// If provided, all rows will share this event token.
/// If not provided, one will be generated.
pub apriori_debug_log_event_token: Option<&'e DebugLogEventToken>,
/// The user associated with these debug log events (if any).
pub maybe_creator_user_token: Option<&'e UserToken>,
/// The events to insert.
pub events: Vec<DebugLogEvent>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// A single debug log event to insert.
///
/// The `message` field should be a pre-serialized JSON string (or any text).
/// Callers should use `serde_json::to_string(...)` before constructing this.
pub struct DebugLogEvent {
pub debug_log_type: DebugLogType,
pub message: String,
}
/// Bulk-insert debug log rows. All rows share the same event token.
///
/// Returns the event token used. No-op (with a warning) if `events` is empty.
pub async fn bulk_insert_debug_logs<'e, 'c: 'e, E>(
args: BulkInsertDebugLogsArgs<'e, 'c, E>,
) -> Result<DebugLogEventToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let event_token = match args.apriori_debug_log_event_token {
Some(token) => token.clone(),
None => DebugLogEventToken::generate(),
};
if args.events.is_empty() {
warn!("bulk_insert_debug_logs called with no events; no-op.");
return Ok(event_token);
}
let maybe_user_token_str = args.maybe_creator_user_token.map(|t| t.as_str().to_string());
let mut query_builder = QueryBuilder::new(
"INSERT INTO debug_logs (event_token, debug_log_type, maybe_creator_user_token, message) VALUES "
);
for (i, event) in args.events.iter().enumerate() {
query_builder.push("(");
query_builder.push_bind(event_token.as_str());
query_builder.push(", ");
query_builder.push_bind(event.debug_log_type.to_str());
query_builder.push(", ");
query_builder.push_bind(&maybe_user_token_str);
query_builder.push(", ");
query_builder.push_bind(&event.message);
query_builder.push(")");
if i < args.events.len() - 1 {
query_builder.push(", ");
}
}
query_builder.build()
.execute(args.mysql_executor)
.await?;
Ok(event_token)
}
@@ -1,62 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
pub struct InsertDebugLogArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// If provided, the row will use this event token.
/// If not provided, one will be generated.
pub apriori_debug_log_event_token: Option<&'e DebugLogEventToken>,
/// The user associated with this debug log event (if any).
pub maybe_creator_user_token: Option<&'e UserToken>,
/// The type of debug log event.
pub debug_log_type: DebugLogType,
/// The log message body (pre-serialized JSON or any text).
pub message: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a single debug log row.
///
/// Returns the event token used.
pub async fn insert_debug_log<'e, 'c: 'e, E>(
args: InsertDebugLogArgs<'e, 'c, E>,
) -> Result<DebugLogEventToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let event_token = match args.apriori_debug_log_event_token {
Some(token) => token.clone(),
None => DebugLogEventToken::generate(),
};
sqlx::query!(
r#"
INSERT INTO debug_logs
SET
event_token = ?,
debug_log_type = ?,
maybe_creator_user_token = ?,
message = ?
"#,
event_token.as_str(),
args.debug_log_type.to_str(),
args.maybe_creator_user_token.map(|t| t.as_str()),
args.message,
)
.execute(args.mysql_executor)
.await?;
Ok(event_token)
}
@@ -1,83 +0,0 @@
use std::marker::PhantomData;
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
const DEFAULT_LIMIT: u32 = 50;
pub struct ListDebugLogsForTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub event_token: &'e DebugLogEventToken,
pub limit: Option<u32>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct DebugLogRow {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug)]
struct RawDebugLogRow {
id: u64,
event_token: DebugLogEventToken,
debug_log_type: DebugLogType,
maybe_creator_user_token: Option<UserToken>,
message: String,
created_at: NaiveDateTime,
}
pub async fn list_debug_logs_for_token<'e, 'c: 'e, E>(
args: ListDebugLogsForTokenArgs<'e, 'c, E>,
) -> Result<Vec<DebugLogRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(200) as i64;
let rows = sqlx::query_as!(
RawDebugLogRow,
r#"
SELECT
id as `id: u64`,
event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
message,
created_at
FROM debug_logs
WHERE event_token = ?
ORDER BY id ASC
LIMIT ?
"#,
args.event_token.as_str(),
limit,
)
.fetch_all(args.mysql_executor)
.await?;
let results = rows.into_iter().map(|row| {
DebugLogRow {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at.and_utc(),
}
}).collect();
Ok(results)
}
@@ -1,3 +0,0 @@
pub mod bulk_insert_debug_logs;
pub mod insert_debug_log;
pub mod list_debug_logs_for_token;
@@ -9,7 +9,6 @@ use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
@@ -30,7 +29,6 @@ pub struct FalJobDetails {
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -53,7 +51,6 @@ struct RawJobRecord {
creator_ip_address: String,
maybe_prompt_token: Option<PromptToken>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
@@ -91,7 +88,6 @@ SELECT
jobs.creator_ip_address,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
jobs.created_at,
jobs.updated_at
@@ -133,7 +129,6 @@ fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<FalJobDetai
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
maybe_prompt_token: record.maybe_prompt_token,
maybe_debug_log_event_token: record.maybe_debug_log_event_token,
created_at: record.created_at,
updated_at: record.updated_at,
})
@@ -65,7 +65,6 @@ pub async fn insert_generic_inference_job_for_fal_queue<'e, 'c : 'e, E>(args: In
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
};
@@ -68,7 +68,6 @@ pub async fn insert_generic_inference_job_for_fal_queue_mock_failure<'e, 'c: 'e,
starting_job_status_override: Some(JobStatusPlus::CompleteFailure),
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
maybe_failure_reason: args.maybe_failure_reason,
maybe_debug_log_event_token: None,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
};
@@ -13,7 +13,6 @@ use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
@@ -48,8 +47,6 @@ pub struct InsertGenericInferenceForFalWithAprioriJobTokenArgs<'e, 'c, E>
/// Set to `Some(JobStatusPlus::CompleteFailure)` for mock/test failure jobs.
pub starting_job_status_override: Option<JobStatusPlus>,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
pub maybe_failure_reason: Option<&'e str>,
@@ -142,8 +139,6 @@ SET
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
frontend_failure_category = ?,
failure_reason = ?,
@@ -169,8 +164,6 @@ SET
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
maybe_frontend_failure_category_str,
maybe_truncated_failure_reason,
@@ -1,6 +1,5 @@
use anyhow::anyhow;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::common::job_status_plus::JobStatusPlus;
@@ -15,52 +14,10 @@ pub struct MarkJobFailedByTokenArgs<'a> {
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
}
pub struct MarkJobFailedByTokenFromConnectionArgs<'a> {
pub mysql_connection: &'a mut PoolConnection<MySql>,
pub job_token: &'a InferenceJobToken,
pub maybe_public_failure_reason: Option<&'a str>,
pub internal_debugging_failure_reason: &'a str,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
}
/// Permanently mark an inference job as failed, looked up by its token.
/// Uses a pool to acquire a connection.
/// Unlike `mark_generic_inference_job_failure`, this does not allow retries —
/// the job will always land in `complete_failure`.
pub async fn mark_job_failed_by_token(args: MarkJobFailedByTokenArgs<'_>) -> AnyhowResult<()> {
execute_mark_failed(ExecuteMarkJobFailedByTokenArgs {
executor: args.pool,
job_token: args.job_token,
maybe_public_failure_reason: args.maybe_public_failure_reason,
internal_debugging_failure_reason: args.internal_debugging_failure_reason,
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
}).await
}
/// Permanently mark an inference job as failed, looked up by its token.
/// Uses an existing connection.
pub async fn mark_job_failed_by_token_from_connection(args: MarkJobFailedByTokenFromConnectionArgs<'_>) -> AnyhowResult<()> {
execute_mark_failed(ExecuteMarkJobFailedByTokenArgs {
executor: &mut **args.mysql_connection,
job_token: args.job_token,
maybe_public_failure_reason: args.maybe_public_failure_reason,
internal_debugging_failure_reason: args.internal_debugging_failure_reason,
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
}).await
}
struct ExecuteMarkJobFailedByTokenArgs<'a, E> {
executor: E,
job_token: &'a InferenceJobToken,
maybe_public_failure_reason: Option<&'a str>,
internal_debugging_failure_reason: &'a str,
maybe_frontend_failure_category: Option<FrontendFailureCategory>,
}
async fn execute_mark_failed<'e, E>(
args: ExecuteMarkJobFailedByTokenArgs<'_, E>,
) -> AnyhowResult<()>
where
E: sqlx::Executor<'e, Database = MySql>,
{
let maybe_public_failure_reason = args.maybe_public_failure_reason.map(|reason| {
let mut reason = reason.trim().to_string();
reason.truncate(512);
@@ -89,7 +46,7 @@ WHERE token = ?
args.maybe_frontend_failure_category,
args.job_token.as_str()
)
.execute(args.executor)
.execute(args.pool)
.await;
match query_result {
@@ -1,6 +1,5 @@
pub mod fal;
pub mod job;
pub mod moderation;
pub mod seedance2pro;
pub mod web;
pub mod worldlabs;
@@ -1,172 +0,0 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
pub struct GetJobByTokenForModerationArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub job_token: &'e InferenceJobToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct ModerationJobDetails {
pub token: InferenceJobToken,
pub uuid_idempotency_token: String,
pub maybe_job_type: Option<String>,
pub maybe_product_category: Option<String>,
pub inference_category: String,
pub maybe_external_third_party: Option<String>,
pub maybe_external_third_party_id: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_inference_args: Option<String>,
pub maybe_download_url: Option<String>,
pub on_success_result_entity_type: Option<String>,
pub on_success_result_entity_token: Option<String>,
pub on_success_result_batch_token: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<String>,
pub creator_ip_address: String,
pub creator_set_visibility: String,
pub maybe_wallet_ledger_entry_token: Option<String>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub status: String,
pub attempt_count: u16,
pub failure_reason: Option<String>,
pub internal_debugging_failure_reason: Option<String>,
pub frontend_failure_category: Option<String>,
pub maybe_routing_tag: Option<String>,
pub assigned_worker: Option<String>,
pub assigned_cluster: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug)]
struct RawRow {
token: InferenceJobToken,
uuid_idempotency_token: String,
maybe_job_type: Option<String>,
maybe_product_category: Option<String>,
inference_category: String,
maybe_external_third_party: Option<String>,
maybe_external_third_party_id: Option<String>,
maybe_model_type: Option<String>,
maybe_model_token: Option<String>,
maybe_prompt_token: Option<PromptToken>,
maybe_inference_args: Option<String>,
maybe_download_url: Option<String>,
on_success_result_entity_type: Option<String>,
on_success_result_entity_token: Option<String>,
on_success_result_batch_token: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<String>,
creator_ip_address: String,
creator_set_visibility: String,
maybe_wallet_ledger_entry_token: Option<String>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
status: String,
attempt_count: u16,
failure_reason: Option<String>,
internal_debugging_failure_reason: Option<String>,
frontend_failure_category: Option<String>,
maybe_routing_tag: Option<String>,
assigned_worker: Option<String>,
assigned_cluster: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
pub async fn get_job_by_token_for_moderation<'e, 'c: 'e, E>(
args: GetJobByTokenForModerationArgs<'e, 'c, E>,
) -> Result<Option<ModerationJobDetails>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let maybe_row = sqlx::query_as!(
RawRow,
r#"
SELECT
token as `token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
uuid_idempotency_token,
job_type as `maybe_job_type`,
product_category as `maybe_product_category`,
inference_category,
maybe_external_third_party,
maybe_external_third_party_id,
maybe_model_type,
maybe_model_token,
maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
maybe_inference_args,
maybe_download_url,
on_success_result_entity_type,
on_success_result_entity_token,
on_success_result_batch_token,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
maybe_creator_anonymous_visitor_token,
creator_ip_address,
creator_set_visibility,
maybe_wallet_ledger_entry_token,
maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
status,
attempt_count as `attempt_count: u16`,
failure_reason,
internal_debugging_failure_reason,
frontend_failure_category,
maybe_routing_tag,
assigned_worker,
assigned_cluster,
created_at,
updated_at
FROM generic_inference_jobs
WHERE token = ?
"#,
args.job_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(maybe_row.map(|row| ModerationJobDetails {
token: row.token,
uuid_idempotency_token: row.uuid_idempotency_token,
maybe_job_type: row.maybe_job_type,
maybe_product_category: row.maybe_product_category,
inference_category: row.inference_category,
maybe_external_third_party: row.maybe_external_third_party,
maybe_external_third_party_id: row.maybe_external_third_party_id,
maybe_model_type: row.maybe_model_type,
maybe_model_token: row.maybe_model_token,
maybe_prompt_token: row.maybe_prompt_token,
maybe_inference_args: row.maybe_inference_args,
maybe_download_url: row.maybe_download_url,
on_success_result_entity_type: row.on_success_result_entity_type,
on_success_result_entity_token: row.on_success_result_entity_token,
on_success_result_batch_token: row.on_success_result_batch_token,
maybe_creator_user_token: row.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: row.maybe_creator_anonymous_visitor_token,
creator_ip_address: row.creator_ip_address,
creator_set_visibility: row.creator_set_visibility,
maybe_wallet_ledger_entry_token: row.maybe_wallet_ledger_entry_token,
maybe_debug_log_event_token: row.maybe_debug_log_event_token,
status: row.status,
attempt_count: row.attempt_count,
failure_reason: row.failure_reason,
internal_debugging_failure_reason: row.internal_debugging_failure_reason,
frontend_failure_category: row.frontend_failure_category,
maybe_routing_tag: row.maybe_routing_tag,
assigned_worker: row.assigned_worker,
assigned_cluster: row.assigned_cluster,
created_at: row.created_at,
updated_at: row.updated_at,
}))
}
@@ -1 +0,0 @@
pub mod get_job_by_token_for_moderation;
@@ -11,7 +11,6 @@ use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
@@ -41,8 +40,6 @@ pub struct InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs<'e, 'c,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
@@ -106,8 +103,6 @@ SET
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
status = ?
"#,
args.apriori_job_token.as_str(),
@@ -132,8 +127,6 @@ SET
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
STATUS.to_str(),
);
@@ -5,15 +5,14 @@ use sqlx::pool::PoolConnection;
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
@@ -98,7 +97,6 @@ SELECT
jobs.maybe_model_token,
jobs.maybe_raw_inference_text,
jobs.maybe_inference_args,
jobs.maybe_prompt_token,
jobs.frontend_failure_category as maybe_frontend_failure_category,
jobs.failure_reason,
@@ -268,7 +266,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
maybe_model_title: maybe_model_title.map(|title| title.to_string()),
maybe_raw_inference_text: record.maybe_raw_inference_text,
maybe_inference_args: record.maybe_inference_args,
maybe_prompt_token: record.maybe_prompt_token,
maybe_style_name,
},
maybe_result_details,
@@ -303,7 +300,6 @@ struct RawGenericInferenceJobStatus {
pub maybe_model_token: Option<String>,
pub maybe_raw_inference_text: Option<String>,
pub maybe_inference_args: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_result_entity_type: Option<String>,
pub maybe_result_entity_token: Option<String>,
@@ -363,7 +359,6 @@ impl FromRow<'_, MySqlRow> for RawGenericInferenceJobStatus {
maybe_model_token: row.try_get("maybe_model_token")?,
maybe_raw_inference_text: row.try_get("maybe_raw_inference_text")?,
maybe_inference_args: row.try_get("maybe_inference_args")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
maybe_result_entity_type: row.try_get("maybe_result_entity_type")?,
maybe_result_entity_token: row.try_get("maybe_result_entity_token")?,
maybe_result_batch_token: row.try_get("maybe_result_batch_token")?,
@@ -12,7 +12,6 @@ use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::i8_to_bool;
@@ -56,7 +55,6 @@ SELECT
jobs.maybe_model_token,
jobs.maybe_raw_inference_text,
jobs.maybe_inference_args,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,
jobs.failure_reason,
@@ -243,7 +241,6 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
maybe_model_title: maybe_model_title.map(|title| title.to_string()),
maybe_raw_inference_text: record.maybe_raw_inference_text,
maybe_inference_args: record.maybe_inference_args,
maybe_prompt_token: record.maybe_prompt_token,
maybe_style_name,
},
maybe_result_details,
@@ -276,7 +273,6 @@ struct RawGenericInferenceJobStatus {
pub maybe_model_token: Option<String>,
pub maybe_raw_inference_text: Option<String>,
pub maybe_inference_args: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_result_entity_type: Option<String>,
pub maybe_result_entity_token: Option<String>,
@@ -8,7 +8,6 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
/// Shared inference job status struct for several queries (list, get, batch get)
@@ -58,9 +57,6 @@ pub struct RequestDetails {
/// Raw JSON payload of polymorphic inference args
pub maybe_inference_args: Option<String>,
/// Prompt token linking this job to its prompt record.
pub maybe_prompt_token: Option<PromptToken>,
/// For Comfy / Video Style Transfer jobs, this might include
/// the name of the selected style.
pub maybe_style_name: Option<StyleTransferName>,
@@ -15,7 +15,6 @@ use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
@@ -164,7 +163,6 @@ SELECT
jobs.maybe_model_token,
jobs.maybe_raw_inference_text,
jobs.maybe_inference_args,
jobs.maybe_prompt_token,
jobs.frontend_failure_category as maybe_frontend_failure_category,
jobs.failure_reason,
@@ -334,7 +332,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
maybe_model_title: maybe_model_title.map(|title| title.to_string()),
maybe_raw_inference_text: record.maybe_raw_inference_text,
maybe_inference_args: record.maybe_inference_args,
maybe_prompt_token: record.maybe_prompt_token,
maybe_style_name,
},
maybe_result_details,
@@ -369,7 +366,6 @@ struct RawGenericInferenceJobStatus {
pub maybe_model_token: Option<String>,
pub maybe_raw_inference_text: Option<String>,
pub maybe_inference_args: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_result_entity_type: Option<String>,
pub maybe_result_entity_token: Option<String>,
@@ -429,7 +425,6 @@ impl FromRow<'_, MySqlRow> for RawGenericInferenceJobStatus {
maybe_model_token: row.try_get("maybe_model_token")?,
maybe_raw_inference_text: row.try_get("maybe_raw_inference_text")?,
maybe_inference_args: row.try_get("maybe_inference_args")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
maybe_result_entity_type: row.try_get("maybe_result_entity_type")?,
maybe_result_entity_token: row.try_get("maybe_result_entity_token")?,
maybe_result_batch_token: row.try_get("maybe_result_batch_token")?,
@@ -8,7 +8,6 @@ pub mod billing;
pub mod browser_session_logs;
pub mod characters;
pub mod comments;
pub mod debug_logs;
pub mod database_time;
pub mod email_sender_jobs;
pub mod entity_stats;
@@ -1,70 +0,0 @@
use sqlx::mysql::MySqlRow;
use sqlx::pool::PoolConnection;
use sqlx::{FromRow, MySql, QueryBuilder, Row};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::traits::mysql_from_row::MySqlFromRow;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
/// A prompt context item with its owning prompt token, for batch queries.
pub struct BatchPromptContextItem {
pub prompt_token: PromptToken,
pub media_token: MediaFileToken,
pub context_semantic_type: PromptContextSemanticType,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
}
impl FromRow<'_, MySqlRow> for BatchPromptContextItem {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
prompt_token: PromptToken::try_from_mysql_row(row, "prompt_token")?,
media_token: MediaFileToken::try_from_mysql_row(row, "media_token")?,
context_semantic_type: PromptContextSemanticType::try_from_mysql_row(row, "context_semantic_type")?,
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
})
}
}
/// Batch fetch all context items for multiple prompts in a single query.
/// Returns items grouped by their prompt_token (via the prompt_token field).
pub async fn batch_list_prompt_context_items(
prompt_tokens: &[PromptToken],
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Vec<BatchPromptContextItem>, sqlx::Error> {
if prompt_tokens.is_empty() {
return Ok(Vec::new());
}
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(r#"
SELECT
pci.prompt_token,
pci.media_token,
pci.context_semantic_type,
m.public_bucket_directory_hash,
m.maybe_public_bucket_prefix,
m.maybe_public_bucket_extension
FROM prompt_context_items pci
JOIN media_files m ON pci.media_token = m.token
WHERE pci.prompt_token IN (
"#);
let mut separated = query_builder.separated(", ");
for token in prompt_tokens {
separated.push_bind(token.to_string());
}
separated.push_unseparated(") ");
let query = query_builder.build_query_as::<BatchPromptContextItem>();
let results = query.fetch_all(&mut **mysql_connection).await?;
Ok(results)
}
@@ -1,3 +1,2 @@
pub mod batch_list_prompt_context_items;
pub mod insert_batch_prompt_context_items;
pub mod list_prompt_context_items;
@@ -1,95 +0,0 @@
use chrono::{DateTime, Utc};
use sqlx::mysql::MySqlRow;
use sqlx::pool::PoolConnection;
use sqlx::{FromRow, MySql, QueryBuilder, Row};
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use enums::traits::mysql_from_row::MySqlFromRow;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
pub struct BatchPromptResult {
pub token: PromptToken,
pub prompt_type: PromptType,
pub maybe_model_type: Option<CommonModelType>,
pub maybe_generation_provider: Option<GenerationProvider>,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_generation_mode: Option<CommonGenerationMode>,
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
pub maybe_resolution: Option<CommonResolution>,
pub maybe_batch_count: Option<u8>,
pub maybe_generate_audio: Option<bool>,
pub maybe_duration_seconds: Option<u32>,
pub created_at: DateTime<Utc>,
}
impl FromRow<'_, MySqlRow> for BatchPromptResult {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token: PromptToken::try_from_mysql_row(row, "token")?,
prompt_type: PromptType::try_from_mysql_row(row, "prompt_type")?,
maybe_model_type: row.try_get::<Option<String>, _>("maybe_model_type")?
.and_then(|s| CommonModelType::from_str(&s).ok()),
maybe_generation_provider: row.try_get::<Option<String>, _>("maybe_generation_provider")?
.and_then(|s| GenerationProvider::from_str(&s).ok()),
maybe_positive_prompt: row.try_get("maybe_positive_prompt")?,
maybe_negative_prompt: row.try_get("maybe_negative_prompt")?,
maybe_generation_mode: row.try_get::<Option<String>, _>("maybe_generation_mode")?
.and_then(|s| CommonGenerationMode::from_str(&s).ok()),
maybe_aspect_ratio: row.try_get::<Option<String>, _>("maybe_aspect_ratio")?
.and_then(|s| CommonAspectRatio::from_str(&s).ok()),
maybe_resolution: row.try_get::<Option<String>, _>("maybe_resolution")?
.and_then(|s| CommonResolution::from_str(&s).ok()),
maybe_batch_count: row.try_get("maybe_batch_count")?,
maybe_generate_audio: row.try_get("maybe_generate_audio")?,
maybe_duration_seconds: row.try_get("maybe_duration_seconds")?,
created_at: row.try_get("created_at")?,
})
}
}
pub async fn batch_get_prompts(
tokens: &[PromptToken],
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Vec<BatchPromptResult>, sqlx::Error> {
if tokens.is_empty() {
return Ok(Vec::new());
}
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(r#"
SELECT
p.token,
p.prompt_type,
p.maybe_model_type,
p.maybe_generation_provider,
p.maybe_positive_prompt,
p.maybe_negative_prompt,
p.maybe_generation_mode,
p.maybe_aspect_ratio,
p.maybe_resolution,
p.maybe_batch_count,
p.maybe_generate_audio,
p.maybe_duration_seconds,
p.created_at
FROM prompts as p
WHERE p.token IN (
"#);
let mut separated = query_builder.separated(", ");
for token in tokens {
separated.push_bind(token.to_string());
}
separated.push_unseparated(") ");
let query = query_builder.build_query_as::<BatchPromptResult>();
query.fetch_all(&mut **mysql_connection).await
}
@@ -1,3 +1,2 @@
pub mod batch_get_prompts;
pub mod get_prompt;
pub mod insert_prompt;
@@ -1,160 +0,0 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 24;
/// Used in the `debug_logs` table in a `VARCHAR(24)` field.
///
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DebugLogType {
HttpRequest,
FalRequest,
KinoviRequest,
FalWebhook,
}
impl_enum_display_and_debug_using_to_str!(DebugLogType);
impl_mysql_enum_coders!(DebugLogType);
impl_mysql_from_row!(DebugLogType);
impl DebugLogType {
pub fn to_str(&self) -> &'static str {
match self {
Self::HttpRequest => "http_request",
Self::FalRequest => "fal_request",
Self::KinoviRequest => "kinovi_request",
Self::FalWebhook => "fal_webhook",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"http_request" => Ok(Self::HttpRequest),
"fal_request" => Ok(Self::FalRequest),
"kinovi_request" => Ok(Self::KinoviRequest),
"fal_webhook" => Ok(Self::FalWebhook),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::HttpRequest,
Self::FalRequest,
Self::KinoviRequest,
Self::FalWebhook,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::debug_logs::debug_log_type::DebugLogType;
use crate::by_table::debug_logs::debug_log_type::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(DebugLogType::HttpRequest, "http_request");
assert_serialization(DebugLogType::FalRequest, "fal_request");
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
}
#[test]
fn to_str() {
assert_eq!(DebugLogType::HttpRequest.to_str(), "http_request");
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
}
#[test]
fn from_str() {
assert_eq!(DebugLogType::from_str("http_request").unwrap(), DebugLogType::HttpRequest);
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
}
#[test]
fn from_str_err() {
let result = DebugLogType::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = DebugLogType::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(DebugLogType::all_variants().len(), DebugLogType::iter().len());
}
#[test]
fn round_trip() {
for variant in DebugLogType::all_variants() {
assert_eq!(variant, DebugLogType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, DebugLogType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, DebugLogType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
for variant in DebugLogType::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
}
for variant in DebugLogType::all_variants() {
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
}
}
#[test]
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
for variant in DebugLogType::all_variants() {
let to_str_value = variant.to_str();
assert!(valid_pattern.is_match(to_str_value),
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
}
}
}
}
@@ -1 +0,0 @@
pub mod debug_log_type;
@@ -4,7 +4,6 @@ pub mod batch_generations;
pub mod characters;
pub mod beta_keys;
pub mod comments;
pub mod debug_logs;
pub mod email_sender_jobs;
pub mod entity_stats;
pub mod featured_items;
@@ -77,16 +77,12 @@ pub enum TaskModelType {
Kling3p0Standard,
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_1p5_pro")]
Seedance1p5Pro,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -158,11 +154,9 @@ impl TaskModelType {
Self::Kling2p6Pro => "kling_2p6_pro",
Self::Kling3p0Standard => "kling_3p0_standard",
Self::Kling3p0Pro => "kling_3p0_pro",
Self::HappyHorse1p0 => "happy_horse_1p0",
Self::Seedance10Lite => "seedance_1.0_lite",
Self::Seedance1p5Pro => "seedance_1p5_pro",
Self::Seedance2p0 => "seedance_2p0",
Self::Seedance2p0Fast => "seedance_2p0_fast",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -214,11 +208,9 @@ impl TaskModelType {
"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_1.0_lite" => Ok(Self::Seedance10Lite),
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
"seedance_2p0" => Ok(Self::Seedance2p0),
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -273,11 +265,9 @@ impl TaskModelType {
Self::Kling2p6Pro,
Self::Kling3p0Standard,
Self::Kling3p0Pro,
Self::HappyHorse1p0,
Self::Seedance10Lite,
Self::Seedance1p5Pro,
Self::Seedance2p0,
Self::Seedance2p0Fast,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -339,11 +329,9 @@ mod tests {
assert_serialization(TaskModelType::Kling2p6Pro, "kling_2p6_pro");
assert_serialization(TaskModelType::Kling3p0Standard, "kling_3p0_standard");
assert_serialization(TaskModelType::Kling3p0Pro, "kling_3p0_pro");
assert_serialization(TaskModelType::HappyHorse1p0, "happy_horse_1p0");
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
assert_serialization(TaskModelType::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(TaskModelType::Seedance2p0, "seedance_2p0");
assert_serialization(TaskModelType::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(TaskModelType::Sora2, "sora_2");
assert_serialization(TaskModelType::Sora2Pro, "sora_2_pro");
assert_serialization(TaskModelType::Veo2, "veo_2");
@@ -394,11 +382,9 @@ mod tests {
assert_eq!(TaskModelType::Kling2p6Pro.to_str(), "kling_2p6_pro");
assert_eq!(TaskModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
assert_eq!(TaskModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
assert_eq!(TaskModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite");
assert_eq!(TaskModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
assert_eq!(TaskModelType::Seedance2p0.to_str(), "seedance_2p0");
assert_eq!(TaskModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast");
assert_eq!(TaskModelType::Sora2.to_str(), "sora_2");
assert_eq!(TaskModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(TaskModelType::Veo2.to_str(), "veo_2");
@@ -449,11 +435,9 @@ mod tests {
assert_eq!(TaskModelType::from_str("kling_2p6_pro").unwrap(), TaskModelType::Kling2p6Pro);
assert_eq!(TaskModelType::from_str("kling_3p0_standard").unwrap(), TaskModelType::Kling3p0Standard);
assert_eq!(TaskModelType::from_str("kling_3p0_pro").unwrap(), TaskModelType::Kling3p0Pro);
assert_eq!(TaskModelType::from_str("happy_horse_1p0").unwrap(), TaskModelType::HappyHorse1p0);
assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite);
assert_eq!(TaskModelType::from_str("seedance_1p5_pro").unwrap(), TaskModelType::Seedance1p5Pro);
assert_eq!(TaskModelType::from_str("seedance_2p0").unwrap(), TaskModelType::Seedance2p0);
assert_eq!(TaskModelType::from_str("seedance_2p0_fast").unwrap(), TaskModelType::Seedance2p0Fast);
assert_eq!(TaskModelType::from_str("sora_2").unwrap(), TaskModelType::Sora2);
assert_eq!(TaskModelType::from_str("sora_2_pro").unwrap(), TaskModelType::Sora2Pro);
assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2);
@@ -484,7 +468,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 48);
assert_eq!(variants.len(), 46);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
@@ -517,11 +501,9 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling2p6Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Standard));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::HappyHorse1p0));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance1p5Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0Fast));
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2));
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
@@ -30,7 +30,6 @@ pub(crate) enum TokenPrefix {
BrowserSessionLog,
Character,
Comment,
DebugLogEvent,
DownloadJob,
EmailSenderJob,
GoogleSignInAccount,
@@ -117,7 +116,6 @@ impl PrefixGenerator for TokenPrefix {
Self::BrowserSessionLog => "bsl_",
Self::Character => "character_",
Self::Comment => "comment_",
Self::DebugLogEvent => "dbg_",
Self::DownloadJob => "jdown_", // NB: Previously "JGUP:"
Self::EmailSenderJob => "email_job_",
Self::GoogleSignInAccount => "gsi_",
@@ -47,6 +47,3 @@ pub mod wallets;
pub mod zs_voice_dataset_samples;
pub mod zs_voice_datasets;
pub mod zs_voices;
// Non-unique tokens (used as indices, not primary keys)
pub mod non_unique;
@@ -1,15 +0,0 @@
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// A non-unique event token for the `debug_logs` table.
/// Multiple log rows can share the same event token.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct DebugLogEventToken(pub String);
impl_string_token!(DebugLogEventToken);
impl_crockford_generator!(DebugLogEventToken, 32usize, TokenPrefix::DebugLogEvent, CrockfordLower);
@@ -1 +0,0 @@
pub mod debug_logs_event_token;
@@ -94,7 +94,6 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
character_references_supported: Some(true),
character_references_max: Some(9),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
@@ -134,7 +133,6 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
character_references_supported: Some(true),
character_references_max: Some(9),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
@@ -85,8 +85,6 @@ 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::debug_logs::moderation_list_debug_logs_for_token_handler::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_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::*;
@@ -157,7 +155,6 @@ use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
use artcraft_api_defs::prompts::batch_get_prompts::*;
use artcraft_api_defs::prompts::get_prompt::*;
use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse};
use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse};
@@ -223,7 +220,6 @@ use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEnti
use enums::by_table::user_ratings::entity_type::UserRatingEntityType;
use enums::by_table::user_ratings::rating_value::UserRatingValue;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
@@ -406,7 +402,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
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::batch_get_prompts_handler::batch_get_prompts_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,
@@ -521,8 +516,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler,
crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler,
crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler,
crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler,
crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::moderation_get_job_by_token_handler,
crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler,
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
@@ -566,7 +559,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
MediaFileOriginProductCategory,
MediaFileSubtype,
MediaFileType,
CommonModelClass,
CommonModelType,
CommonAspectRatio,
CommonGenerationMode,
@@ -629,9 +621,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
BatchGetInferenceJobStatusQueryParams,
BatchGetInferenceJobStatusSuccessResponse,
BatchGetMediaFilesModelInfo,
BatchGetPromptsQuery,
BatchGetPromptsResponse,
BatchPromptInfo,
CreatePromptRequest,
CreatePromptResponse,
BatchGetMediaFilesQueryParams,
@@ -873,6 +862,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GetMediaFilePathInfo,
GetMediaFileSuccessResponse,
GetProfilePathInfo,
GetPromptError,
GetPromptPathInfo,
GetPromptSuccessResponse,
GetUnifiedQueueStatsError,
@@ -913,13 +903,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListAllImpersonationRequestsQueryParams,
ListAllImpersonationRequestsSuccessResponse,
ListImpersonationRequestsPathInfo,
GetJobByTokenPathInfo,
GetJobByTokenSuccessResponse,
ModerationJobResponse,
ListDebugLogsPathInfo,
ListDebugLogsQueryParams,
ListDebugLogsSuccessResponse,
DebugLogEntry,
ListStaffAuditLogsQueryParams,
ListStaffAuditLogsSuccessResponse,
ListImpersonationRequestsQueryParams,
@@ -238,7 +238,6 @@ pub async fn flux_2_lora_edit_image_angle_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -239,7 +239,6 @@ pub async fn qwen_edit_2511_edit_image_angle_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -265,7 +265,6 @@ pub async fn gemini_25_flash_edit_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -297,7 +297,6 @@ pub async fn gpt_image_1_edit_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;

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