Compare commits

..
Author SHA1 Message Date
Tanish Manku 318a285b60 PageScene: drop spacebar handler from MouseControls
The spacebar branch in onkeydown called switchCameraView; remove it
along with the now-unused switchCameraView callback param/field. Editor
no longer passes that arg into MouseControls' constructor.
2026-05-01 00:09:16 -07:00
Tanish Manku bccdd8df5b PageScene: collapse Editor facades and relocate scene-object methods
Drops the remaining 1-line forwarders on Editor and rewires their
callers to talk to the owning subsystem directly.

Inlined at call sites then deleted:
- updateOutliner → selection.refreshOutliner (3 actions/* callers)
- publishSelect → selection.publishSelect (1 action + helper.ts)
- updateSelectedUI → selection.updateSelectedUI (in-engine callers)
- cacheScene → save_manager.getSceneJson (EngineProvider)
- setColor → activeScene.setColor (ControlPanelSceneObject)
- isObjectLipsync deleted entirely (zero callers)

Relocated:
- lockUnlockObject + isObjectLocked moved onto SelectionBridge. The
  bridge gains a SelectionBridgeDeps surface for the userData mutation
  (still in SceneUtils as toggleObjectLocked) and the gizmo
  attach/detach. Callers (Outliner, ControlPanelSceneObject) now go
  through editor.selection.
- setSelectedObject (which despite the name wrote position/rotation/
  scale onto the active selection) becomes
  SceneManager.updateSelectedTransform — same data ownership, no more
  reverse dependency from helper.ts into sceneManager.selected_objects.

helper.ts shrinks to removeTransformControls, isObjectLocked,
toggleObjectLocked, getSelectedSum, deleteObject. The pre-existing
TS errors in deleteObject (Mesh.texture / Material[] dispose) are
untouched — out of scope for this refactor.
2026-04-30 23:55:27 -07:00
Tanish Manku 5ec73e362d PageScene: move change_mode + toggleTransformSpace into GizmoController
GizmoController now reads/writes the store's transformSpace through a
new GizmoControllerDeps surface that Editor wires up in initialize().

- changeMode(type) reads the desired space from the store internally
  (forces local for scale), so callers don't plumb space through.
- toggleTransformSpace() owns the read/flip/write/apply loop that used
  to live on Editor.toggleTransformSpace.

Editor.change_mode and Editor.toggleTransformSpace are deleted.

Callers updated:
- actions/setTransformMode.ts → editor.gizmo.changeMode(...)
- Controls3D.tsx → editorEngine?.gizmo.toggleTransformSpace()
- keymap.ts → editor.gizmo.changeMode + .toggleTransformSpace; the
  inline setGizmoMode body shrinks to one line and the duplicate
  toggleTransformSpace helper is gone.
2026-04-30 23:50:16 -07:00
Tanish Manku 89ac302392 PageScene: move switchCameraView into CameraController via DI
CameraController now owns switchCameraView. The body lifted from
SceneUtils.switchCameraView gets cross-subsystem capabilities
(scene access, transform-controls removal, selection write,
editor-state and object-panel store actions) through a new
CameraControllerDeps object that Editor wires up in initialize().

This keeps subsystems from importing each other directly: the
controller sees a narrow callback surface, not SelectionBridge or
SceneUtils. Same precedent as the existing ViewportController setters.

Deletes:
- SceneUtils.switchCameraView (helper.ts)
- Editor.switchCameraView facade

The space-bar handler in MouseControls now points directly at
cameraController.switchCameraView via the callback Editor passes in.
2026-04-30 23:47:59 -07:00
Tanish Manku 554a76d35f PageScene: strip dead playback/recording machinery from Editor
Removed Editor fields/methods left over from a removed video-export
feature: rendering, switchPreviewToggle, processingRecording,
processingHasFailed, recorder, globalSetTrackLengthSeconds,
startRenderHeight/Width, selectedCanvas, togglePlayback, updateLoad,
showLoading, endLoading. Every read of these flags was permanently
false; whole branches in renderScene/renderSingleFrame were unreachable.

The space-bar handler in keybinds_controls.ts was also dead today
(selectedCanvas was never set true) — rewired it to call
editor.switchCameraView() directly via a callback so pressing Space
toggles camera view as intended.

MouseControls' constructor drops the four dead params (selectedCanvas,
switchPreviewToggle, rendering, togglePlayback) and gains a single
switchCameraView callback in their place.
2026-04-30 23:45:49 -07:00
Tanish Manku 1d091e2d75 PageScene: extract CameraController from editor.ts
Owns the camera state, FreeCam plumbing, render aspect ratio, and the
per-frame camera tick. The camera-side of renderSingleFrame now reduces
to a single cameraController.tickPerFrame(delta) call that handles the
FOV-from-store sync, FreeCam integration with store write-back, the
camera_person_mode cam_obj transfer, and the render_camera mirror.

Editor still constructs the cameras and lockControls during initialize()
because they need the renderer's domElement, but the fields land on
cameraController. External callers in helper.ts, save_manager.ts,
useFreeCam.ts, setCameraAspect.ts, and ControlsTopButtons.tsx now reach
through editor.cameraController; no Editor-level forwarders.

editor.ts: 1074 -> 941 LOC.
2026-04-30 18:12:27 -07:00
Tanish Manku e06abd36bd PageScene: extract SelectionBridge from editor.ts
Fourth step of the editor.ts decomposition. Pulls the engine→store
selection-and-outliner sync surface out of Editor into a dedicated
module.

SelectionBridge owns:
  selected (current inspected object)
  last_selected_sum (frame-to-frame transform-change check)

Methods take their dependencies as method arguments / closure-based
deps — no `editor` reference, no `import type Editor`, no circular
import. Constructor takes:
  getSceneManager: () => SceneManager | undefined  (lazy — created later)
  cameraName: string
  version: number

Methods:
  setSelected, publishSelect, updateSelectedUI, refreshOutliner,
  updateOutliner, getAssetType, getCharactersByUuid, isCharacterUuid.

Editor exposes the bridge as `editor.selection` and constructs it in
its constructor. Three facades stay on Editor for the external callers:
  editor.publishSelect()    — actions/selectObject.ts, helper.ts
  editor.updateOutliner()   — actions/add{Object,Character,Shape}.ts
  editor.updateSelectedUI() — helper.ts
ControlPanelSceneObject reads `editor.selected` via a forwarding getter
that returns this.selection.selected.

The four near-identical "render_outliner + setOutlinerItems" snippets
that used to live in Editor.initialize / newScene / loadScene /
deleteObject collapse into one place: selection.refreshOutliner().

Dead code removed during the move:
  - outliner_feature_flag was hardcoded `true` (with `// TODO REMOVE`),
    making the false-branch fallback paths in updateSelectedUI /
    publishSelect unreachable. Field and all `if` branches dropped.
  - Editor.last_selected: passed by value to MouseControls (which
    immediately overwrites it with []), and helper.ts wrote to it but
    nothing read. Pure write-only dead state.
  - The `return 0` on a void-returning method that triggered the
    "Not all code paths return a value" typecheck error — gone with
    updateSelectedUI's body.
  - Unused AssetType / ClipGroup imports in editor.ts.

helper.ts updates:
  - removeTransformControls drops the dead last_selected write.
  - switchCameraView and deleteObject now write
    editor.selection.selected (instead of editor.selected) so the
    forwarding getter still surfaces the current value.

Net: editor.ts down to ~1074 LOC. Typecheck baseline drops by 1
(the void-return error is gone).
2026-04-30 14:00:56 -07:00
Tanish Manku abf2e1d6c2 PageScene: extract GizmoController from editor.ts
Third step of the editor.ts decomposition. Pulls TransformControls
ownership and the gizmo lifecycle out of Editor into a focused module.

GizmoController owns:
  control (TransformControls).

Methods take their dependencies as method arguments — no `editor`
reference, no `import type Editor`, no circular import:
  configure(camera, domElement, scene, callbacks)
  changeMode(type, space)
  setSpace(space)
  canToggleSpace()
  attach(object), detach()
  addToScene(scene), removeFromScene(scene)
  setVisible(v), isVisible()

Editor exposes the gizmo as `editor.gizmo` and constructs it empty in
the constructor; configure() is called from initialize() once camera
and renderer exist. The two existing public methods stay on Editor as
facades, so the external callers (actions/setTransformMode.ts,
comps/Controls3D.tsx, engine/keymap.ts) keep working unchanged:
  Editor.change_mode reads the store's transformSpace and forwards.
  Editor.toggleTransformSpace toggles the store and pushes into gizmo.

External readers updated:
  helper.ts (SceneUtils.lockUnlockObject + removeTransformControls)
    routes through editor.gizmo.attach/detach/addToScene/removeFromScene
    instead of poking editor.control directly.
  Editor.snapShotOfCurrentFrame uses editor.gizmo.setVisible /
    isVisible instead of touching control.visible.
  Editor passes editor.gizmo.control to the MouseControls constructor
    where it previously passed editor.control.

Dead code removed:
  - transform_interaction on Editor: passed by value to MouseControls
    (which has its own internal copy that overrides) and the `= true`
    write in change_mode mutated a field nothing read. Pure dead code.
  - The "if (this.control == undefined) return;" guard immediately
    after `new TransformControls(...)` — that branch was unreachable.
  - Unused TransformControls import in editor.ts.

Net: editor.ts down to 1196 LOC. Typecheck error count drops by 3.
2026-04-30 11:05:00 -07:00
Tanish Manku 4c521ab55c PageScene: extract PostProcessingPipeline from editor.ts
Second step of the editor.ts decomposition. Pulls both EffectComposer
chains (main + raw) and the post-process passes the editor actually uses
out of Editor into a dedicated subsystem.

PostProcessingPipeline owns:
  composer, render_composer, outlinePass, customOutlinerPass, surfaceFinder.

Methods take their dependencies as method arguments — no `editor`
reference, no `import type Editor`, no circular import:
  configureMain(renderer, scene, camera, w, h)
  configureRaw(rawRenderer, scene, renderCamera, w, h)
  resize(w, h)
  dispose()
  updateSurfaceIdAttributeToMesh(scene)

Editor exposes the pipeline as `editor.postProcessing` and constructs it
in the constructor (empty) before Scene, since Scene's load paths invoke
updateSurfaceIdAttributeToMesh as a callback. The Scene constructor now
receives a closure that dispatches into the pipeline.

ViewportController stays decoupled from composer internals: PR 1's
resizePostProcessing callback now delegates to postProcessing.resize(w, h),
which encapsulates the optional-chaining dance for composers that may not
exist yet at the first resize.

External readers updated:
  helper.ts (SceneUtils.removeTransformControls) reads
    editor.postProcessing.outlinePass instead of editor.outlinePass.
  Editor.snapShotOfCurrentFrame and Editor.renderScene route through
    editor.postProcessing.composer / .render_composer.
  Editor.unmountEngine calls editor.postProcessing.dispose() instead of
    the two composer.dispose() calls.

Dead code removed during the move:
  - bloomPass (created but never added; the addPass call was already commented out)
  - smaaPass, ssaoPass (declared but never instantiated)
  - renderPass, rawRenderPass, outputPass, renderOutputPass fields (only
    added to a composer once, never read again — now locals)
  - setRenderDepth, setNormalMap, setOutlineRender (no callers — only
    setColorMap was called from configureRaw, and it's now inlined)
  - the corresponding three-of-four `Material` uniforms typecheck errors
  - 9 unused three.js imports in editor.ts
    (EffectComposer, RenderPass, OutlinePass, OutputPass, SMAAPass,
    SAOPass, UnrealBloomPass, CustomOutlinePass, FindSurfaces, SSAOPass)

Net: editor.ts loses ~140 LOC and the post-processing concern entirely;
typecheck error baseline drops by 4 (3 dead mode setters + 1 unused
import).
2026-04-30 10:41:22 -07:00
Tanish Manku c352edd38c PageScene: extract ViewportController from editor.ts
First step of the editor.ts decomposition (refactor/3d-editor-cleanup).
Pulls the canvas/container DOM seam, the ResizeObserver, and the resize
cascade out of the ~1450-LOC Editor class into a standalone module.

ViewportController owns container, canvReference, canvasRenderCamReference,
the ResizeObserver, lastCanvasSize, plus the onWindowResize / observed
resize logic.

Engine fields are forwarded explicitly through the `engine` constructor
argument:
  - cameras, renderer, render aspect ratio: closure-based getters that
    resolve at call time
  - post-processing: a single resizePostProcessing(w, h) callback rather
    than three composer field getters

The single callback is deliberate. The original code creates the main
composer in _configurePostProcessing but the raw composer + custom
outline pass much later in _configurePostProcessingRaw, with the first
viewport.onWindowResize() landing between the two. Three separate
composer getters would have ViewportController peeking at fields that
don't exist yet at that first resize and silently no-op'ing via `?.`.
The callback hides that lifecycle concern inside the post-processing
module, where it belongs.

No `import type Editor`, no circular import. The dead `update*` and
`*MayReset` accessors that the original code carried as defensive
fallbacks (with self-deprecating "we should not need this" comments)
were never actually called and are dropped here. containerMayReset
stays — it has live internal callers.

Editor exposes the controller as `editor.viewport` and routes its
internal call sites through `this.viewport.<method>()`.

Net: editor.ts loses ~107 lines and one cohesive responsibility.
2026-04-30 10:37:29 -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
482 changed files with 6359 additions and 22946 deletions
@@ -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"
}
Generated
-2
View File
@@ -11469,14 +11469,12 @@ name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_client",
"clap 4.5.60",
"easyenv",
"errors",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
]
@@ -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;
@@ -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.
@@ -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,45 +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
## 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()`
## 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()`
- External crates used: `artcraft_client` (media file download), `seedance2pro_client` (video gen)
## Subcommands
- `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] [--reference_media_tokens "token1,token2"] [--localhost] [--download_path /tmp/media_files]`
@@ -11,10 +11,8 @@ path = "src/main.rs"
easyenv.workspace = true
# Internal
artcraft_client.workspace = true
errors.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
@@ -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;
@@ -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",
@@ -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")?,
@@ -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;
@@ -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));
@@ -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,
@@ -155,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};
@@ -221,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;
@@ -404,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,
@@ -562,7 +559,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
MediaFileOriginProductCategory,
MediaFileSubtype,
MediaFileType,
CommonModelClass,
CommonModelType,
CommonAspectRatio,
CommonGenerationMode,
@@ -625,9 +621,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
BatchGetInferenceJobStatusQueryParams,
BatchGetInferenceJobStatusSuccessResponse,
BatchGetMediaFilesModelInfo,
BatchGetPromptsQuery,
BatchGetPromptsResponse,
BatchPromptInfo,
CreatePromptRequest,
CreatePromptResponse,
BatchGetMediaFilesQueryParams,
@@ -869,6 +862,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GetMediaFilePathInfo,
GetMediaFileSuccessResponse,
GetProfilePathInfo,
GetPromptError,
GetPromptPathInfo,
GetPromptSuccessResponse,
GetUnifiedQueueStatsError,
@@ -30,7 +30,6 @@ use redis::Commands;
use redis_common::redis_keys::RedisKeys;
use server_environment::ServerEnvironment;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::{IntoParams, ToSchema};
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
@@ -74,9 +73,6 @@ pub struct BatchInferenceJobStatusResponsePayload {
#[derive(Serialize, ToSchema)]
pub struct BatchRequestDetailsResponse {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
@@ -257,7 +253,6 @@ fn db_record_to_response_payload(
job_token: record.job_token,
request: BatchRequestDetailsResponse {
inference_category: record.request_details.inference_category,
maybe_prompt_token: record.request_details.maybe_prompt_token,
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
maybe_model_token: record.request_details.maybe_model_token,
maybe_model_title: record.request_details.maybe_model_title,
@@ -28,7 +28,6 @@ use redis::{Commands, RedisResult};
use redis_common::redis_keys::RedisKeys;
use server_environment::ServerEnvironment;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::ToSchema;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
@@ -65,9 +64,6 @@ pub struct InferenceJobStatusResponsePayload {
#[derive(Serialize, ToSchema)]
pub struct RequestDetailsResponse {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
@@ -252,7 +248,6 @@ fn record_to_payload(
job_token: record.job_token,
request: RequestDetailsResponse {
inference_category: record.request_details.inference_category,
maybe_prompt_token: record.request_details.maybe_prompt_token,
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
maybe_model_token: record.request_details.maybe_model_token,
maybe_model_title: record.request_details.maybe_model_title,
@@ -207,7 +207,6 @@ fn db_record_to_response_payload(
job_token: record.job_token,
request: ListSessionRequestDetailsResponse {
inference_category: record.request_details.inference_category,
maybe_prompt_token: record.request_details.maybe_prompt_token,
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
maybe_model_token: record.request_details.maybe_model_token,
maybe_model_title: record.request_details.maybe_model_title,
@@ -29,13 +29,13 @@ use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
/// plan, all in one place.
pub struct DistilledImageRequest {
/// The fully-built router request.
pub request: GenerateImageRequest,
request: GenerateImageRequest,
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
pub cost: ImageGenerationCostEstimate,
/// Execution plan as computed by the Fal provider — what we hand to the router.
pub plan: ImageGenerationPlan,
plan: ImageGenerationPlan,
}
impl DistilledImageRequest {
@@ -9,9 +9,6 @@ use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
@@ -19,11 +16,11 @@ use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
@@ -156,12 +153,12 @@ pub async fn omni_gen_image_generate_handler(
// ==================== EXECUTE GENERATION ==================== //
let fal_client = RouterFalClient::new(
let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new(
server_state.fal.api_key.clone(),
server_state.fal.webhook_url.clone(),
);
let router_client = RouterClient::Fal(fal_client);
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
let generation_response = distilled.plan().generate_image(&router_client)
.await
@@ -171,10 +168,10 @@ pub async fn omni_gen_image_generate_handler(
})?;
let external_job_id = match &generation_response {
GenerateImageResponse::Artcraft(p) => {
artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Artcraft(p) => {
p.inference_job_token.as_str().to_string()
}
GenerateImageResponse::Fal(p) => {
artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Fal(p) => {
p.request_id.clone().unwrap_or_default()
}
};
@@ -209,11 +206,11 @@ pub async fn omni_gen_image_generate_handler(
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(generation_mode),
maybe_aspect_ratio: request.aspect_ratio, // TODO: should be saved from router's decision as it could have changed
maybe_resolution: request.resolution,// TODO: should be saved from router's decision as it could have changed
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: request.image_batch_count.map(|c| c as u8),
maybe_generate_audio: None, // NB: Images, not video
maybe_duration_seconds: None, // NB: Images, not video
maybe_generate_audio: None,
maybe_duration_seconds: None,
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
@@ -1,6 +1,5 @@
use log::{error, warn};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
@@ -8,7 +7,8 @@ use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_infe
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use uuid_utils::uuid::generate_random_uuid;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::shared_job_args::SharedJobArgs;
@@ -19,12 +19,7 @@ pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
pub shared: SharedJobArgs<'a, 'tx>,
}
pub struct InsertSeedance2proJobsResult {
pub primary_job_token: InferenceJobToken,
pub all_job_tokens: Vec<InferenceJobToken>,
}
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InsertSeedance2proJobsResult, AdvancedCommonWebError> {
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
let InsertSeedance2proJobsArgs {
primary_order_id,
maybe_additional_order_ids,
@@ -34,7 +29,6 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
// Build a deduplicated list with primary_order_id first.
let mut all_order_ids = vec![primary_order_id.to_string()];
if let Some(additional) = maybe_additional_order_ids {
for id in additional {
if id != primary_order_id {
@@ -47,11 +41,7 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
for (i, order_id) in all_order_ids.iter().enumerate() {
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
// TODO: These strings are too long for the database:
//let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
let idempotency_str = generate_random_uuid();
let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
@@ -79,13 +69,8 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
}
}
let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
})?;
Ok(InsertSeedance2proJobsResult {
primary_job_token,
all_job_tokens,
})
}
@@ -23,7 +23,6 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
@@ -291,10 +290,10 @@ pub async fn omni_gen_video_generate_handler(
// -- Inference job --
let (primary_job_token, all_job_tokens) = match &pipeline_result.response {
let job_token = match &pipeline_result.response {
GenerateVideoResponse::Seedance2Pro(payload) => {
info!("Inserting seedance2pro job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
primary_order_id: &payload.order_id,
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
@@ -307,8 +306,7 @@ pub async fn omni_gen_video_generate_handler(
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(result.primary_job_token, result.all_job_tokens)
}).await?
}
GenerateVideoResponse::Fal(payload) => {
let external_id = payload.request_id.as_deref().ok_or_else(|| {
@@ -316,7 +314,7 @@ pub async fn omni_gen_video_generate_handler(
AdvancedCommonWebError::server_error_with_message("Fal generation response missing request_id")
})?;
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_fal_job(InsertFalJobArgs {
insert_fal_job(InsertFalJobArgs {
external_job_id: external_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
@@ -327,18 +325,10 @@ pub async fn omni_gen_video_generate_handler(
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}).await?
}
GenerateVideoResponse::Artcraft(payload) => {
(
payload.inference_job_token.clone(),
vec![payload.inference_job_token.clone()],
)
payload.inference_job_token.clone()
}
other => {
error!("Unexpected generation response variant: {:?}", other);
@@ -353,8 +343,7 @@ pub async fn omni_gen_video_generate_handler(
Ok(Json(OmniGenVideoGenerateResponse {
success: true,
inference_job_token: primary_job_token,
all_job_tokens,
inference_job_token: job_token,
}))
}
@@ -1,155 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web_lab::extract::Query;
use artcraft_api_defs::prompts::batch_get_prompts::{
BatchGetPromptsQuery, BatchGetPromptsResponse, BatchPromptInfo,
};
use artcraft_api_defs::prompts::get_prompt::GetPromptImageContextItem;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use log::warn;
use mysql_queries::queries::prompt_context_items::batch_list_prompt_context_items::batch_list_prompt_context_items;
use mysql_queries::queries::prompts::batch_get_prompts::batch_get_prompts;
use tokens::tokens::prompts::PromptToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::state::server_state::ServerState;
const MAX_BATCH_SIZE: usize = 100;
/// Batch get details on multiple prompts.
///
/// Called, eg. {{api_host}}/v1/prompt/batch?tokens={val}&tokens={val}&tokens=...
#[utoipa::path(
get,
tag = "Prompts",
path = "/v1/prompt/batch",
params(BatchGetPromptsQuery),
responses(
(status = 200, description = "Found", body = BatchGetPromptsResponse),
(status = 400, description = "Bad request"),
(status = 500, description = "Server error"),
),
)]
pub async fn batch_get_prompts_handler(
http_request: HttpRequest,
query: Query<BatchGetPromptsQuery>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BatchGetPromptsResponse>, AdvancedCommonWebError> {
if query.tokens.len() > MAX_BATCH_SIZE {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
format!("tokens must contain at most {} items", MAX_BATCH_SIZE),
));
}
// Deduplicate and trim whitespace
let unique_tokens: Vec<PromptToken> = query.tokens
.iter()
.map(|t| PromptToken::new_from_str(t.trim()))
.filter(|t| !t.as_str().is_empty())
.collect::<HashSet<_>>()
.into_iter()
.collect();
if unique_tokens.is_empty() {
return Ok(Json(BatchGetPromptsResponse {
success: true,
prompts: Vec::new(),
}));
}
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Batch query: fetch all prompts in one query
let results = batch_get_prompts(&unique_tokens, &mut mysql_connection)
.await
.map_err(|err| {
warn!("Batch get prompts query error: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
// Batch query: fetch all context items for all prompts in one query
let all_context_items = batch_list_prompt_context_items(&unique_tokens, &mut mysql_connection)
.await
.map_err(|err| {
warn!("Batch list prompt context items query error: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
let media_domain = get_media_domain(&http_request);
// Group context items by prompt token
let mut context_items_map: HashMap<String, Vec<GetPromptImageContextItem>> = HashMap::new();
for item in all_context_items {
// Filter to only image-like context types
match item.context_semantic_type {
PromptContextSemanticType::VidStartFrame
| PromptContextSemanticType::VidEndFrame
| PromptContextSemanticType::Imgref
| PromptContextSemanticType::ImgrefCharacter
| PromptContextSemanticType::ImgrefStyle
| PromptContextSemanticType::ImgrefBg
| PromptContextSemanticType::VidRef => {} // Include
_ => continue, // Ignore
}
let bucket_path = MediaFileBucketPath::from_object_hash(
&item.public_bucket_directory_hash,
item.maybe_public_bucket_prefix.as_deref(),
item.maybe_public_bucket_extension.as_deref(),
);
let context_item = GetPromptImageContextItem {
media_token: item.media_token,
semantic: item.context_semantic_type,
media_links: MediaLinksBuilder::from_media_path_and_env(
media_domain,
server_state.server_environment,
&bucket_path,
),
};
context_items_map
.entry(item.prompt_token.as_str().to_string())
.or_default()
.push(context_item);
}
// Assemble final response
let prompts: Vec<BatchPromptInfo> = results
.into_iter()
.map(|result| {
let maybe_context_images = context_items_map
.remove(result.token.as_str())
.filter(|items| !items.is_empty());
BatchPromptInfo {
token: result.token,
maybe_model_class: result.maybe_model_type.map(|ty| ty.get_model_class()),
maybe_model_type: result.maybe_model_type,
maybe_generation_provider: result.maybe_generation_provider,
maybe_positive_prompt: result.maybe_positive_prompt,
maybe_negative_prompt: result.maybe_negative_prompt,
maybe_generation_mode: result.maybe_generation_mode,
maybe_aspect_ratio: result.maybe_aspect_ratio,
maybe_resolution: result.maybe_resolution,
maybe_batch_count: result.maybe_batch_count,
maybe_generate_audio: result.maybe_generate_audio,
maybe_duration_seconds: result.maybe_duration_seconds,
maybe_context_images,
created_at: result.created_at,
}
})
.collect();
Ok(Json(BatchGetPromptsResponse {
success: true,
prompts,
}))
}
@@ -1,7 +1,14 @@
use std::fmt;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpMessage, HttpRequest};
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Path;
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use artcraft_api_defs::prompts::get_prompt::{
GetPromptImageContextItem, GetPromptPathInfo, GetPromptSuccessResponse,
PromptInfo, PromptInfoModeratorFields,
@@ -11,11 +18,38 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use log::{error, warn};
use mysql_queries::queries::prompt_context_items::list_prompt_context_items::list_prompt_context_items;
use mysql_queries::queries::prompts::get_prompt::get_prompt_from_connection;
use utoipa::ToSchema;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::state::server_state::ServerState;
#[derive(Debug, ToSchema)]
pub enum GetPromptError {
ServerError,
NotFound,
}
impl ResponseError for GetPromptError {
fn status_code(&self) -> StatusCode {
match *self {
GetPromptError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
GetPromptError::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
GetPromptError::ServerError => "server error".to_string(),
GetPromptError::NotFound => "not found".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for GetPromptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// Get details on a prompt.
#[utoipa::path(
@@ -24,8 +58,8 @@ use crate::state::server_state::ServerState;
path = "/v1/prompts/{token}",
responses(
(status = 200, description = "Found", body = GetPromptSuccessResponse),
(status = 404, description = "Not found"),
(status = 500, description = "Server error"),
(status = 404, description = "Not found", body = GetPromptError),
(status = 500, description = "Server error", body = GetPromptError),
),
params(
("path" = GetPromptPathInfo, description = "Path for Request")
@@ -34,32 +68,44 @@ use crate::state::server_state::ServerState;
pub async fn get_prompt_handler(
http_request: HttpRequest,
path: Path<GetPromptPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetPromptSuccessResponse>, AdvancedCommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, GetPromptError>
{
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("Error acquiring MySQL connection: {:?}", err);
GetPromptError::ServerError
})?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
})?;
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
GetPromptError::ServerError
})?;
let is_moderator = maybe_user_session
.map(|session| session.can_ban_users)
.unwrap_or(false);
.map(|session| session.can_ban_users)
.unwrap_or(false);
let prompt_token = path.into_inner().token;
let result = get_prompt_from_connection(&prompt_token, &mut mysql_connection)
.await
.map_err(|err| {
warn!("query error: {:?}", err);
AdvancedCommonWebError::from(err)
})?
.ok_or(AdvancedCommonWebError::NotFound)?;
let result = get_prompt_from_connection(
&prompt_token,
&mut mysql_connection
).await;
let result = match result {
Err(e) => {
warn!("query error: {:?}", e);
return Err(GetPromptError::ServerError);
}
Ok(None) => return Err(GetPromptError::NotFound),
Ok(Some(result)) => result,
};
let mut maybe_style_name = None;
let mut maybe_strength = None;
@@ -68,12 +114,14 @@ pub async fn get_prompt_handler(
let mut maybe_travel_prompt = None;
let mut maybe_frame_skip = None;
// Flags
let mut used_face_detailer = false;
let mut used_upscaler = false;
let mut lipsync_enabled = false;
let mut lcm_disabled = false;
let mut use_cinematic = false;
// Moderator fields
let mut main_ipa_workflow = None;
let mut face_detailer_workflow = None;
let mut upscaler_workflow = None;
@@ -88,12 +136,14 @@ pub async fn get_prompt_handler(
maybe_travel_prompt = inner_payload.travel_prompt.clone();
maybe_frame_skip = inner_payload.frame_skip;
// Flags
used_face_detailer = inner_payload.used_face_detailer.unwrap_or(false);
used_upscaler = inner_payload.used_upscaler.unwrap_or(false);
lipsync_enabled = inner_payload.lipsync_enabled.unwrap_or(false);
lcm_disabled = inner_payload.disable_lcm.unwrap_or(false);
use_cinematic = inner_payload.use_cinematic.unwrap_or(false);
// Moderator fields
main_ipa_workflow = inner_payload.main_ipa_workflow.clone();
face_detailer_workflow = inner_payload.face_detailer_workflow.clone();
upscaler_workflow = inner_payload.upscaler_workflow.clone();
@@ -114,7 +164,7 @@ pub async fn get_prompt_handler(
let items_result = list_prompt_context_items(
&result.token,
&mut mysql_connection,
&mut mysql_connection
).await;
let items = items_result.unwrap_or_else(|e| {
@@ -139,8 +189,7 @@ pub async fn get_prompt_handler(
let bucket_path = MediaFileBucketPath::from_object_hash(
&item.public_bucket_directory_hash,
item.maybe_public_bucket_prefix.as_deref(),
item.maybe_public_bucket_extension.as_deref(),
);
item.maybe_public_bucket_extension.as_deref());
Some(GetPromptImageContextItem {
media_token: item.media_token.clone(),
@@ -159,7 +208,7 @@ pub async fn get_prompt_handler(
Some(items)
};
Ok(Json(GetPromptSuccessResponse {
let response = GetPromptSuccessResponse {
success: true,
prompt: PromptInfo {
token: result.token,
@@ -190,5 +239,12 @@ pub async fn get_prompt_handler(
maybe_global_ipa_image_token,
maybe_frame_skip,
},
}))
};
let body = serde_json::to_string(&response)
.map_err(|e| GetPromptError::ServerError)?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(body))
}
@@ -1,3 +1,2 @@
pub mod batch_get_prompts_handler;
pub mod create_prompt_handler;
pub mod get_prompt_handler;
@@ -1,4 +1,3 @@
use crate::http_server::endpoints::prompts::batch_get_prompts_handler::batch_get_prompts_handler;
use crate::http_server::endpoints::prompts::create_prompt_handler::create_prompt_handler;
use crate::http_server::endpoints::prompts::get_prompt_handler::get_prompt_handler;
use actix_helpers::route_builder::RouteBuilder;
@@ -22,7 +21,6 @@ where
RouteBuilder::from_app(app)
// NB: This poor RouteBuilder utility requires that POST comes first, otherwise the GET glob
// will capture it and force 504 Method Not Allowed for all POSTs.
.add_get("/v1/prompt/batch", batch_get_prompts_handler)
.add_post("/v1/prompts/create", create_prompt_handler)
.add_get("/v1/prompts/{token}", get_prompt_handler)
.into_app()
@@ -343,10 +343,10 @@
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"description": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
"markdownDescription": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
}
]
}
+1 -1
View File
@@ -34,7 +34,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Instrument+Serif:ital@0;1&family=Figtree:ital,wght@0,300..900;1,300..900&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
</head>
@@ -7,7 +7,6 @@ import { ToastContainer } from "../components/toast/toast";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import LandingSD2 from "../pages/landing-sd2";
import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
@@ -30,8 +29,7 @@ export function App() {
<Navbar />
<Routes>
<Route path="/" element={<Landing3 />} />
<Route path="/landing3" element={<Landing3 />} />
<Route path="/" element={<Landing2 />} />
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
@@ -117,7 +117,7 @@ export const SignupForm = ({
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<Input
@@ -131,7 +131,7 @@ export const SignupForm = ({
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Password
</label>
<div className="relative">
@@ -155,7 +155,7 @@ export const SignupForm = ({
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
@@ -4,7 +4,7 @@ import { Button } from "@storyteller/ui-button";
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BillingApi } from "@storyteller/api";
import { toast } from "./toast/toast";
import { toast } from "@storyteller/ui-toaster";
interface CreditPack {
id: string;
@@ -101,7 +101,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
</span>
<Button
variant="primary"
className="rounded-full pointer-events-none rounded-lg px-5 py-2 text-sm font-semibold"
className="pointer-events-none rounded-lg px-5 py-2 text-sm font-semibold"
disabled={purchasingId !== null}
>
{purchasingId === pack.id ? "Loading..." : "Purchase"}
@@ -17,7 +17,7 @@ export const DiscordButton = ({
return (
<Button
className={`rounded-full relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
icon={faDiscord}
as="link"
href={SOCIAL_LINKS.DISCORD}
@@ -37,7 +37,7 @@ export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
return (
<Button
className={`rounded-full rounded-xl px-8 py-4 text-md transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
className={`rounded-xl px-8 py-4 text-md transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
disabled={isMobile || !downloadLink}
icon={getIcon()}
as="link"
@@ -111,7 +111,7 @@ export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
{view === "menu" ? (
<div className="space-y-3">
{/* <Button
className="rounded-full w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
onClick={() => {}} // Dummy op
>
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
@@ -119,7 +119,7 @@ export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
</Button> */}
<Button
className="rounded-full w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
onClick={() => setView("signup")}
>
<FontAwesomeIcon icon={faEnvelope} className="text-lg" />
@@ -108,24 +108,6 @@ export default function Example() {
<Link to="/" className="text-gray-400 hover:text-white">
Home
</Link>
<Link
to="/create-image"
className="text-gray-400 hover:text-white"
>
Image
</Link>
<Link
to="/create-video"
className="text-gray-400 hover:text-white"
>
Video
</Link>
<Link to="/download" className="text-gray-400 hover:text-white">
Download
</Link>
<Link to="/pricing" className="text-gray-400 hover:text-white">
Pricing
</Link>
<Link to="/news" className="text-gray-400 hover:text-white">
News
</Link>
@@ -9,7 +9,6 @@ import { Button } from "@storyteller/ui-button";
import { type PopoverItem } from "@storyteller/ui-popover";
import Seo from "../../components/seo";
import Footer from "../../components/footer";
import { TruchetPattern } from "../truchet-pattern";
interface CreateMediaPageShellProps {
// SEO
@@ -70,22 +69,6 @@ export function CreateMediaPageShell({
return (
<div className="relative min-h-screen overflow-x-hidden bg-[#101014] text-white">
<Seo title={title} description={description} />
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
}}
>
<TruchetPattern
variant="auth"
intensity={0.5}
className="absolute inset-0 h-full w-full"
/>
</div>
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
</div>
@@ -136,26 +119,6 @@ export function CreateMediaPageShell({
</div>
))}
{/* Subtle truchet pattern — only on empty state */}
{!hasContent && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
}}
>
<TruchetPattern
variant="content"
intensity={0.5}
className="absolute inset-0 h-full w-full"
/>
</div>
)}
<div className="relative z-[1] h-full w-full">
<div className="flex h-full w-full flex-col">
{!hasContent && (
@@ -1,14 +1,12 @@
import { memo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
export interface FailedCardProps {
id: string;
failureReason?: string;
failureMessage?: string;
prompt: string;
modelId: string;
modelLabel: string;
onDismiss: (id: string) => void;
}
@@ -18,11 +16,9 @@ export const FailedCard = memo(function FailedCard({
failureReason,
failureMessage,
prompt,
modelId,
modelLabel,
onDismiss,
}: FailedCardProps) {
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
return (
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10">
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 sm:px-6">
@@ -48,16 +44,7 @@ export const FailedCard = memo(function FailedCard({
</div>
<div className="absolute inset-x-0 bottom-0 hidden bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 sm:block">
<p className="truncate text-xs text-white/70">{prompt}</p>
<div className="mt-1 flex items-center gap-1.5">
{iconPath && (
<img
src={iconPath}
alt=""
className="h-3 w-3 flex-shrink-0 icon-auto-contrast"
/>
)}
<p className="truncate text-[10px] text-white/40">{modelLabel}</p>
</div>
<p className="truncate text-[10px] text-white/40">{modelLabel}</p>
</div>
</div>
);
@@ -11,10 +11,9 @@ import {
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { Tooltip } from "@storyteller/ui-tooltip";
import { toast } from "../toast/toast";
import { downloadMediaFile } from "../../lib/download-media";
import { toast } from "@storyteller/ui-toaster";
import {
getModelCreatorIconPath,
getModelDisplayName,
@@ -120,7 +119,6 @@ export const GalleryCard = memo(function GalleryCard({
const [ratio, setRatio] = useState<number | undefined>(cached);
const [shareCopied, setShareCopied] = useState(false);
const [isRecreating, setIsRecreating] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const recreateMediaClass: RecreateMediaClass | null = isVideo
@@ -279,23 +277,9 @@ export const GalleryCard = memo(function GalleryCard({
[item, onClick],
);
const handleDownload = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!item.fullImage || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFile({
url: item.fullImage,
filename: `artcraft-${item.id}`,
mediaClass: item.mediaClass,
});
} finally {
setIsDownloading(false);
}
},
[item.fullImage, item.id, item.mediaClass, isDownloading],
);
const downloadHref = item.fullImage
? addCorsParam(item.fullImage) || item.fullImage
: undefined;
return (
<div
@@ -401,20 +385,19 @@ export const GalleryCard = memo(function GalleryCard({
/>
</button>
</Tooltip>
{item.fullImage && (
{downloadHref && (
<Tooltip content="Download" position="top">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
<a
href={downloadHref}
download={`artcraft-${item.id}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
aria-label="Download"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
<FontAwesomeIcon icon={faArrowDownToLine} className="text-sm" />
</a>
</Tooltip>
)}
</div>
@@ -144,14 +144,12 @@ export function GenerationGalleryGrid({
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
/>
)}
{entry.kind === "failed" && (
<FailedCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
@@ -10,7 +10,6 @@ export interface PendingCardProps {
prompt: string;
progress?: number;
estimatedTimeLeftMs?: number;
batchCount?: number;
}
const formatTimeLeft = (ms: number): string => {
@@ -30,7 +29,6 @@ export const PendingCard = memo(function PendingCard({
prompt,
progress,
estimatedTimeLeftMs,
batchCount,
}: PendingCardProps) {
const progressPercent =
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
@@ -46,11 +44,6 @@ export const PendingCard = memo(function PendingCard({
return (
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
<div className="animate-shimmer h-full w-full" />
{batchCount != null && batchCount > 1 && (
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
Generating {batchCount} videos · Results may appear one at a time
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<FontAwesomeIcon
icon={faSpinnerThird}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { JobsApi, JobStatus, MediaFilesApi } from "@storyteller/api";
import type { Job, Prompts } from "@storyteller/api";
import type { Job } from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import {
getModelDisplayName,
@@ -8,7 +8,6 @@ import {
ALL_MODELS_LIST,
} from "@storyteller/model-list";
import type { GalleryItem } from "./useGalleryData";
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -20,7 +19,6 @@ export interface InProgressJob {
progress: number;
estimatedTimeLeftMs?: number;
createdAt: string;
batchCount?: number;
}
export interface FailedJob {
@@ -70,48 +68,21 @@ function getJobMediaType(job: Job): "image" | "video" | "other" {
return "other";
}
function getModelLabel(job: Job, promptsMap?: Map<string, Prompts>): string {
const promptToken = job.request.maybe_prompt_token;
const cachedPrompt = promptToken
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
const modelType =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
const providerKey = cachedPrompt?.maybe_generation_provider ?? modelType;
function getModelLabel(job: Job): string {
const modelType = job.request.maybe_model_type ?? "";
const modelDisplay = modelType ? getModelDisplayName(modelType) : undefined;
const provider = providerKey
? getProviderDisplayName(providerKey.toLowerCase())
const provider = modelType
? getProviderDisplayName(modelType.toLowerCase())
: undefined;
if (modelDisplay && provider) return `${modelDisplay} · ${provider}`;
return modelDisplay || provider || "Unknown model";
if (modelDisplay && provider) return `${modelDisplay} — ${provider}`;
return modelDisplay || job.request.maybe_model_title || "Unknown model";
}
function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
const promptToken = job.request.maybe_prompt_token;
const cached = promptToken
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
return (
cached?.maybe_positive_prompt ||
job.request.maybe_raw_inference_text ||
""
);
function getPrompt(job: Job): string {
return job.request.maybe_raw_inference_text || "";
}
function getModelId(job: Job, promptsMap?: Map<string, Prompts>): string {
const promptToken = job.request.maybe_prompt_token;
const cachedPrompt = promptToken
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
return (
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? ""
);
}
function jobToInProgress(job: Job, promptsMap: Map<string, Prompts>): InProgressJob {
function jobToInProgress(job: Job): InProgressJob {
const now = Date.now();
const createdMs = new Date(job.created_at).getTime();
const modelType = job.request.maybe_model_type;
@@ -135,16 +106,16 @@ function jobToInProgress(job: Job, promptsMap: Map<string, Prompts>): InProgress
return {
id: job.job_token,
prompt: getPrompt(job, promptsMap),
modelId: getModelId(job, promptsMap),
modelLabel: getModelLabel(job, promptsMap),
prompt: getPrompt(job),
modelId: job.request.maybe_model_type ?? "",
modelLabel: getModelLabel(job),
progress,
estimatedTimeLeftMs,
createdAt: job.created_at,
};
}
function jobToFailed(job: Job, promptsMap: Map<string, Prompts>): FailedJob {
function jobToFailed(job: Job): FailedJob {
const failureCategory =
job.status.maybe_failure_category_updated ||
job.status.maybe_failure_category;
@@ -159,9 +130,9 @@ function jobToFailed(job: Job, promptsMap: Map<string, Prompts>): FailedJob {
return {
id: job.job_token,
prompt: getPrompt(job, promptsMap),
modelId: getModelId(job, promptsMap),
modelLabel: getModelLabel(job, promptsMap),
prompt: getPrompt(job),
modelId: job.request.maybe_model_type ?? "",
modelLabel: getModelLabel(job),
failureReason,
failureMessage,
status: job.status.status,
@@ -169,10 +140,7 @@ function jobToFailed(job: Job, promptsMap: Map<string, Prompts>): FailedJob {
};
}
function jobToGalleryItem(
job: Job,
promptsMap?: Map<string, Prompts>,
): GalleryItem | null {
function jobToGalleryItem(job: Job): GalleryItem | null {
const result = job.maybe_result;
if (!result?.entity_token) return null;
@@ -184,7 +152,7 @@ function jobToGalleryItem(
return {
id: result.entity_token,
label: getPrompt(job, promptsMap) || "Generation",
label: getPrompt(job) || "Generation",
thumbnail,
fullImage: result.media_links?.cdn_url || null,
// Sort by job creation time (not completion time) so the completed card
@@ -245,51 +213,13 @@ export function useGenerationJobs(options: {
const apiRef = useRef(new JobsApi());
const mediaApiRef = useRef(new MediaFilesApi());
const [inProgressJobs, setInProgressJobs] = useState<Job[]>([]);
const [failedJobsRaw, setFailedJobsRaw] = useState<Job[]>([]);
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
const [failed, setFailed] = useState<FailedJob[]>([]);
const [newlyCompleted, setNewlyCompleted] = useState<GalleryItem[]>([]);
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
const initialLoadDoneRef = useRef(false);
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const j of inProgressJobs) {
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
}
for (const j of failedJobsRaw) {
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
}
return tokens;
}, [inProgressJobs, failedJobsRaw]);
const promptsMap = usePrompts(promptTokens);
const inProgress = useMemo(
() =>
inProgressJobs
.slice()
.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime(),
)
.map((j) => jobToInProgress(j, promptsMap)),
[inProgressJobs, promptsMap],
);
const failed = useMemo(
() =>
failedJobsRaw
.slice()
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.map((j) => jobToFailed(j, promptsMap)),
[failedJobsRaw, promptsMap],
);
const load = useCallback(async () => {
try {
const response = await apiRef.current.ListRecentJobs();
@@ -300,12 +230,25 @@ export function useGenerationJobs(options: {
// Filter by media type
const filtered = jobs.filter((j) => getJobMediaType(j) === mediaType);
const newInProgress = filtered.filter((j) =>
IN_PROGRESS_STATUSES.has(j.status.status),
);
const newFailed = filtered.filter((j) =>
FAILED_STATUSES.has(j.status.status),
);
// In-progress
const inProg = filtered
.filter((j) => IN_PROGRESS_STATUSES.has(j.status.status))
.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime(),
)
.map(jobToInProgress);
// Failed
const failedJobs = filtered
.filter((j) => FAILED_STATUSES.has(j.status.status))
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.map(jobToFailed);
// Completed
const completedJobs = filtered.filter((j) =>
@@ -321,7 +264,7 @@ export function useGenerationJobs(options: {
);
if (newOnes.length > 0) {
const items = newOnes
.map((j) => jobToGalleryItem(j))
.map(jobToGalleryItem)
.filter((item): item is GalleryItem => item !== null);
if (items.length > 0) {
// Await expansion so the pending card and its completed replacement
@@ -337,7 +280,7 @@ export function useGenerationJobs(options: {
prevCompletedIdsRef.current = completedIdSet;
// Prune duration cache
const activeIds = new Set(newInProgress.map((j) => j.job_token));
const activeIds = new Set(inProg.map((t) => t.id));
for (const id of taskDurationCache.keys()) {
if (!activeIds.has(id)) taskDurationCache.delete(id);
}
@@ -349,8 +292,8 @@ export function useGenerationJobs(options: {
return [...fresh, ...prev];
});
}
setInProgressJobs(newInProgress);
setFailedJobsRaw(newFailed);
setInProgress(inProg);
setFailed(failedJobs);
} catch {
// ignore
}
@@ -374,7 +317,7 @@ export function useGenerationJobs(options: {
async (jobToken: string) => {
try {
await apiRef.current.DeleteJobByToken(jobToken);
setFailedJobsRaw((prev) => prev.filter((f) => f.job_token !== jobToken));
setFailed((prev) => prev.filter((f) => f.id !== jobToken));
} catch {
// ignore
}
@@ -1,242 +0,0 @@
import { forwardRef, useImperativeHandle, useRef } from "react";
import gsap from "gsap";
export interface KnightCinemaHandle {
cinemaEl: HTMLDivElement | null;
videoEl: HTMLVideoElement | null;
topBarEl: HTMLDivElement | null;
bottomBarEl: HTMLDivElement | null;
progressBarEl: HTMLDivElement | null;
timecodeEl: HTMLSpanElement | null;
}
interface KnightCinemaProps {
src: string;
topLabel?: string;
bottomLabel?: string;
}
/**
* Letterbox cinema for the manifesto section: full-bleed scroll-scrubbed
* video framed by black bars carrying meta (brand label, scene label, live
* timecode + progress line). The element refs are exposed via an imperative
* handle so the parent can drive entrance/exit/scrub animations on its own
* GSAP timeline (see `setupKnightCinemaTimeline` below).
*/
export const KnightCinema = forwardRef<KnightCinemaHandle, KnightCinemaProps>(
({ src, topLabel = "SEEDANCE 2.0", bottomLabel = "Knight Sneaking IN THE CASTLE" }, ref) => {
const cinemaRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const topBarRef = useRef<HTMLDivElement>(null);
const bottomBarRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const timecodeRef = useRef<HTMLSpanElement>(null);
useImperativeHandle(ref, () => ({
get cinemaEl() {
return cinemaRef.current;
},
get videoEl() {
return videoRef.current;
},
get topBarEl() {
return topBarRef.current;
},
get bottomBarEl() {
return bottomBarRef.current;
},
get progressBarEl() {
return progressBarRef.current;
},
get timecodeEl() {
return timecodeRef.current;
},
}));
return (
<div aria-hidden className="absolute inset-0 z-20 pointer-events-none">
{/* Full-bleed video, wrapped so we can scale + round-corner it during
the phase 5 exit. overflow-hidden + GPU-promoted transform layer
= smooth resize. */}
<div
ref={cinemaRef}
className="absolute inset-0 overflow-hidden bg-black"
style={{
transformOrigin: "center center",
willChange: "transform, border-radius",
}}
>
<video
ref={videoRef}
src={src}
muted
playsInline
preload="auto"
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
{/* Top letterbox bar */}
<div
ref={topBarRef}
className="absolute top-0 inset-x-0 h-[12vh] bg-black flex items-end px-6 sm:px-10 pb-3 sm:pb-4 will-change-transform"
>
<span className="inline-flex items-center gap-2 text-[10px] sm:text-[11px] uppercase tracking-[0.32em] font-semibold text-white/55">
<span className="inline-block h-1 w-1 rounded-full bg-primary" />
{topLabel}
</span>
</div>
{/* Bottom letterbox bar */}
<div
ref={bottomBarRef}
className="absolute bottom-0 inset-x-0 h-[12vh] bg-black flex items-start justify-between gap-4 px-6 sm:px-10 pt-3 sm:pt-4 will-change-transform"
>
<span className="hidden sm:inline-flex items-center text-[10px] sm:text-[11px] uppercase tracking-[0.32em] font-semibold text-white/55">
{bottomLabel}
</span>
<div className="ml-auto flex items-center gap-3 sm:gap-4 shrink-0">
<div className="relative w-28 sm:w-48 h-px bg-white/15">
<div
ref={progressBarRef}
className="absolute inset-y-0 left-0 bg-white/70"
style={{ width: "0%" }}
/>
</div>
<span
ref={timecodeRef}
className="text-[10px] sm:text-[11px] tabular-nums text-white/65 min-w-[68px] sm:min-w-[80px] text-right"
>
00:00 / 00:00
</span>
</div>
</div>
</div>
);
},
);
KnightCinema.displayName = "KnightCinema";
/**
* Appends the cinema's three animation phases to a GSAP timeline:
* 3. Letterbox bars slide in + cinema fades in
* 4. Scroll-scrubbed video (with live timecode + progress line)
* 5. Bars retract + cinema scales down to navbar width with rounded corners
*
* Call this AFTER you've added the manifesto words / hold / text-fade phases
* to your timeline. The phases are appended at the end (`>`).
*
* Note: the parent timeline's ScrollTrigger should set `invalidateOnRefresh: true`
* so the function-based scale in phase 5 re-evaluates on viewport resize.
*/
export const setupKnightCinemaTimeline = (
tl: gsap.core.Timeline,
knight: KnightCinemaHandle,
) => {
const {
cinemaEl,
videoEl,
topBarEl,
bottomBarEl,
progressBarEl,
timecodeEl,
} = knight;
// Initial state — bars off-screen, cinema invisible. Set on the *element*
// (not in CSS) so a hot-reload during dev doesn't leave residual transforms.
if (topBarEl) gsap.set(topBarEl, { yPercent: -100 });
if (bottomBarEl) gsap.set(bottomBarEl, { yPercent: 100 });
if (cinemaEl)
gsap.set(cinemaEl, { opacity: 0, scale: 1, borderRadius: 0 });
// Phase 3 — bars slide in from off-screen edges, framing the cinema.
// Cinema fades in slightly after the bars start moving so the bars
// register as the framing element first.
if (topBarEl && bottomBarEl) {
tl.to(
[topBarEl, bottomBarEl],
{ yPercent: 0, duration: 4, ease: "power3.out" },
">",
);
}
if (cinemaEl) {
tl.to(
cinemaEl,
{ opacity: 1, duration: 3, ease: "power2.out" },
"<+=0.5",
);
}
// Phase 4 — scroll-scrub the video. GSAP can't tween a video element
// directly, so we tween a proxy `t` from 0→1 and write currentTime each
// frame. Same onUpdate keeps the bottom-bar timecode + progress line in
// sync. Seek calls are throttled to ~30Hz to avoid saturating the decoder
// on larger paint surfaces (1440p+).
if (videoEl) {
const videoProxy = { t: 0 };
const formatTC = (s: number) => {
const safe = Number.isFinite(s) ? Math.max(0, s) : 0;
const m = Math.floor(safe / 60);
const sec = Math.floor(safe % 60);
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
};
const SEEK_INTERVAL_MS = 15;
let lastSeekAt = 0;
tl.to(
videoProxy,
{
t: 0.999,
duration: 22,
ease: "none",
onUpdate: () => {
const now = performance.now();
if (now - lastSeekAt < SEEK_INTERVAL_MS) return;
lastSeekAt = now;
if (!videoEl.duration) return;
const time = videoProxy.t * videoEl.duration;
videoEl.currentTime = time;
if (timecodeEl) {
timecodeEl.textContent = `${formatTC(time)} / ${formatTC(videoEl.duration)}`;
}
if (progressBarEl) {
progressBarEl.style.width = `${videoProxy.t * 100}%`;
}
},
},
">",
);
}
// Phase 5 — exit. Bars retract off-screen, cinema scales down to navbar
// width (Tailwind max-w-6xl = 1152px) and rounds its corners. The scale
// is computed from window.innerWidth so the final card is the same
// absolute pixel width on every screen — paired with the parent
// ScrollTrigger's `invalidateOnRefresh: true` it stays exact on resize.
if (topBarEl && bottomBarEl) {
tl.to(
topBarEl,
{ yPercent: -100, opacity: 0, duration: 2, ease: "power2.in" },
">",
);
tl.to(
bottomBarEl,
{ yPercent: 100, opacity: 0, duration: 2, ease: "power2.in" },
"<",
);
}
if (cinemaEl) {
const NAVBAR_WIDTH_PX = 1152;
tl.to(
cinemaEl,
{
scale: () => Math.min(1, NAVBAR_WIDTH_PX / window.innerWidth),
// Border radius is overspecified relative to the post-scale visual
// size (40 × scale ≈ pillowy 20-25px corners on typical desktops).
borderRadius: 40,
duration: 6,
ease: "power2.inOut",
},
"<+=0.3",
);
}
};
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import { Button } from "@storyteller/ui-button";
import { toast } from "../toast/toast";
import { toast } from "@storyteller/ui-toaster";
import { Gravatar } from "@storyteller/ui-gravatar";
import type { UserInfo } from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,12 +14,12 @@ import {
faImage,
faLink,
faPencil,
faSpinnerThird,
faTrashCan,
faUser,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
@@ -29,7 +29,6 @@ import {
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import { downloadMediaFile } from "../../lib/download-media";
import {
formatAspectRatio,
formatDuration,
@@ -89,7 +88,6 @@ export function LightboxDetails({
}: LightboxDetailsProps) {
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const [isDownloading, setIsDownloading] = useState(false);
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [isPromptClamped, setIsPromptClamped] = useState(false);
@@ -97,7 +95,6 @@ export function LightboxDetails({
useEffect(() => {
setIsPromptExpanded(false);
setIsDownloading(false);
}, [mediaToken]);
useEffect(() => {
@@ -351,7 +348,7 @@ export function LightboxDetails({
{createdAt && (
<InfoRow
label="Created"
value={dayjs(createdAt).format("MMMM D, YYYY h:mm:ss A")}
value={dayjs(createdAt).format("MMMM D, YYYY")}
/>
)}
</div>
@@ -371,33 +368,23 @@ export function LightboxDetails({
>
{shareCopy.copied ? "Copied" : "Share"}
</Button>
<button
type="button"
disabled={!mediaUrl || isDownloading}
onClick={async () => {
if (!mediaUrl || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFile({
url: mediaUrl,
filename: `artcraft-${mediaToken || "media"}`,
});
} finally {
setIsDownloading(false);
}
}}
<a
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
mediaUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
}`}
href={mediaUrl ? addCorsParam(mediaUrl) || mediaUrl : undefined}
download={
mediaUrl ? `artcraft-${mediaToken || "media"}` : undefined
}
aria-disabled={!mediaUrl}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={isDownloading ? "animate-spin" : ""}
/>
{isDownloading ? "Downloading…" : "Download"}
</button>
<FontAwesomeIcon icon={faArrowDownToLine} />
Download
</a>
</div>
{onRecreate && promptData.hasToken && (
<Button
@@ -1,6 +1,6 @@
import { Modal } from "@storyteller/ui-modal";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { toast } from "../toast/toast";
import { toast } from "@storyteller/ui-toaster";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { MediaFilesApi, PromptsApi, type UserInfo } from "@storyteller/api";
@@ -1,319 +0,0 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
interface ManifestoThreeBackgroundProps {
progressRef: React.RefObject<number>;
/**
* When `true`, the render loop skips the expensive `renderer.render` +
* `mixer.update` calls. Used to halt GPU work once the character has walked
* off screen and the canvas is fully covered by other UI — saves significant
* paint cost on high-DPI / large displays.
*/
pausedRef?: React.RefObject<boolean>;
}
export const ManifestoThreeBackground = ({
progressRef,
pausedRef,
}: ManifestoThreeBackgroundProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
// CAPTURE MODE — disabled. Uncomment this block + the matching sections
// below (search "CAPTURE MODE") to record a self-driven walk video via
// `?capture=manifesto`. Used to produce a motion-reference clip for
// Seedance without depending on smooth manual scrolling.
// const captureMode =
// typeof window !== "undefined" &&
// new URLSearchParams(window.location.search).get("capture") ===
// "manifesto";
const scene = new THREE.Scene();
// if (captureMode) {
// // Solid background so the recorded video is self-contained (no alpha).
// scene.background = new THREE.Color(0x101014);
// }
// Long-lens (telephoto) feel — narrow FOV + camera pulled further back.
// Flattens perspective distortion so the scene reads as a 2D-ish plane
// instead of a wide-angle bowl.
const camera = new THREE.PerspectiveCamera(
22,
container.clientWidth / container.clientHeight,
0.1,
100,
);
camera.position.z = 20;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.7;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
// Pre-baked environment for PBR reflections — what makes metals/clearcoat
// actually look like materials instead of flat-shaded primitives.
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
const envScene = new RoomEnvironment();
const envTexture = pmrem.fromScene(envScene, 0.04).texture;
scene.environment = envTexture;
// Cinematic three-point lighting — softer ambient so shadows read deeper,
// strong key (with shadow casting) for grounding, cool blue rim behind
// for silhouette definition against the dark scene.
scene.add(new THREE.HemisphereLight(0x3d6cb0, 0x06060c, 0.12));
const keyLight = new THREE.DirectionalLight(0xffffff, 0.85);
keyLight.position.set(6, 8, 5);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.camera.near = 1;
keyLight.shadow.camera.far = 40;
keyLight.shadow.camera.left = -8;
keyLight.shadow.camera.right = 8;
keyLight.shadow.camera.top = 6;
keyLight.shadow.camera.bottom = -6;
keyLight.shadow.bias = -0.0008;
keyLight.shadow.radius = 4;
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x6ba6ff, 0.8);
rimLight.position.set(-6, 3, -4);
scene.add(rimLight);
const fillLight = new THREE.PointLight(0x3a78d6, 0.3, 20);
fillLight.position.set(-3, -2, 5);
scene.add(fillLight);
// Invisible ground plane — only shows the cast shadow. Sits at the
// character's feet so the contact point reads.
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(40, 20),
new THREE.ShadowMaterial({ opacity: 0.25 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.5;
ground.receiveShadow = true;
scene.add(ground);
// Character loaded async from FBX. The mixer is driven by scroll progress
// (not a real-time clock) so the animation scrubs with the user's scroll.
let character: THREE.Group | null = null;
let mixer: THREE.AnimationMixer | null = null;
let action: THREE.AnimationAction | null = null;
let clipDuration = 0;
let cancelled = false;
// Number of walk cycles played across the full section scroll. >1 keeps
// the legs cycling visibly rather than crawling through a single step.
const WALK_CYCLES = 2;
const characterMaterial = new THREE.MeshPhysicalMaterial({
color: 0x07142a,
metalness: 0.35,
roughness: 0.55,
clearcoat: 0.4,
clearcoatRoughness: 0.35,
envMapIntensity: 0.45,
});
new GLTFLoader().load(
"/3d-models/sneaking-forward.glb",
(gltf) => {
if (cancelled) return;
const root = gltf.scene;
// Mixamo defaults: ~100 unit tall, faces -Z. FBX → GLB conversions
// typically apply a 0.01 cm→m scale, so the GLB lands roughly 100×
// smaller than the FBX. Scale up to match the previous on-screen
// size and rotate so the character faces the direction of travel (+X).
root.scale.setScalar(3.5);
root.rotation.y = Math.PI / 2;
// Override default materials with the scene's PBR look, and enable
// shadow casting so the figure grounds visually.
root.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
mesh.material = characterMaterial;
mesh.castShadow = true;
}
});
scene.add(root);
character = root;
if (gltf.animations.length > 0) {
mixer = new THREE.AnimationMixer(root);
const clip = gltf.animations[0];
clipDuration = clip.duration;
action = mixer.clipAction(clip);
action.play();
// Pause real-time playback — we'll set action.time manually in tick.
// (For CAPTURE MODE: set this to `!captureMode` instead.)
action.paused = true;
}
// CAPTURE MODE: if (captureMode) startCaptureRecording();
},
undefined,
(err: unknown) => console.error("manifesto character load failed", err),
);
const clamp01 = (v: number) => THREE.MathUtils.clamp(v, 0, 1);
// CAPTURE MODE — disabled. Records the canvas in real time and downloads
// the walk as a video. To re-enable: uncomment, restore the call in the
// GLTF load callback above, and the capture branch in the tick below.
// let recorder: MediaRecorder | null = null;
// let captureStart = 0;
// const captureChunks: Blob[] = [];
// const captureDuration = 6; // seconds of recorded walk
// const startCaptureRecording = () => {
// const stream = (
// renderer.domElement as HTMLCanvasElement
// ).captureStream(60);
// // Prefer MP4 — MediaRecorder's webm output has a known broken duration
// // header (file contains all frames but most players report ~1s). MP4
// // writes duration correctly. Fall back to webm where MP4 isn't supported.
// const mimeCandidates = [
// "video/mp4;codecs=h264",
// "video/mp4;codecs=avc1",
// "video/mp4",
// "video/webm;codecs=vp9",
// "video/webm",
// ];
// const mimeType =
// mimeCandidates.find((m) => MediaRecorder.isTypeSupported(m)) ??
// "video/webm";
// const ext = mimeType.startsWith("video/mp4") ? "mp4" : "webm";
// recorder = new MediaRecorder(stream, {
// mimeType,
// videoBitsPerSecond: 12_000_000,
// });
// recorder.ondataavailable = (e) => {
// if (e.data.size > 0) captureChunks.push(e.data);
// };
// recorder.onstop = () => {
// const blob = new Blob(captureChunks, { type: mimeType });
// const url = URL.createObjectURL(blob);
// const a = document.createElement("a");
// a.href = url;
// a.download = `manifesto-walk.${ext}`;
// document.body.appendChild(a);
// a.click();
// a.remove();
// setTimeout(() => URL.revokeObjectURL(url), 1000);
// };
// // Timeslice forces periodic chunk emission instead of buffering
// // everything until stop() — more reliable across browsers.
// recorder.start(100);
// captureStart = performance.now();
// };
let rafId = 0;
// CAPTURE MODE: also track `let lastFrame = performance.now();` and
// `const dt = (now - lastFrame) / 1000; lastFrame = now;` for mixer.update.
const start = performance.now();
const tick = () => {
// Skip the entire render pass when the parent flags us as paused.
// We still loop rAF (cheap) so resuming is instantaneous, but the
// expensive WebGL render + mixer update are gated. Saves 5-15ms per
// frame on high-DPI / large displays once the character is off-screen.
if (pausedRef?.current) {
rafId = requestAnimationFrame(tick);
return;
}
const now = performance.now();
const t = (now - start) / 1000;
if (character && mixer && action && clipDuration > 0) {
// CAPTURE MODE branch (disabled):
// if (captureMode && captureStart > 0) {
// const elapsed = (now - captureStart) / 1000;
// const cp = clamp01(elapsed / captureDuration);
// mixer.update(dt);
// character.position.x = -11 + 25 * cp;
// character.position.y = -2.5 + Math.sin(t * 1.6) * 0.04;
// if (elapsed >= captureDuration && recorder?.state === "recording") {
// recorder.stop();
// }
// } else {
const cp = clamp01(progressRef.current);
// Drive animation time from scroll. Multiplier sets how many walk
// cycles play across the full section so the legs cycle visibly.
// Modulo wraps the time so the loop chains together cleanly.
const animTime = (cp * clipDuration * WALK_CYCLES) % clipDuration;
action.time = animTime;
mixer.update(0);
// Linear scroll-linked traversal. Progress spans the full visible
// duration of the section (top entering viewport → bottom exiting),
// so the text-reveal end falls at ~p=0.55 (sticky duration / total
// visible duration). At that point the character is around mid-frame.
// From p=0.55 → 1.0 the character continues walking and exits to
// the right while the section scrolls away.
character.position.x = -11 + 25 * cp;
character.position.y = -2.5 + Math.sin(t * 1.6) * 0.04;
// }
}
renderer.render(scene, camera);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
const handleResize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener("resize", handleResize);
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
// CAPTURE MODE: if (recorder && recorder.state === "recording") recorder.stop();
window.removeEventListener("resize", handleResize);
if (renderer.domElement.parentNode === container) {
container.removeChild(renderer.domElement);
}
if (mixer) mixer.stopAllAction();
if (character) {
scene.remove(character);
character.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
mesh.geometry?.dispose();
}
});
}
characterMaterial.dispose();
ground.geometry.dispose();
(ground.material as THREE.Material).dispose();
envTexture.dispose();
pmrem.dispose();
renderer.dispose();
};
}, [progressRef]);
return (
<div
ref={containerRef}
aria-hidden
className="pointer-events-none absolute inset-0 z-[1]"
/>
);
};
export default ManifestoThreeBackground;
@@ -14,7 +14,7 @@ interface ModelBadgeGridProps {
}
const baseBadgeClasses =
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-[#121212] text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5";
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-white/10 text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5";
const highlightClasses =
"bg-primary/30 text-white font-semibold shadow-lg border-2 border-primary/60";
@@ -103,16 +103,16 @@ export default function ModelBadgeGrid({
className={`select-none relative z-10 h-full overflow-hidden ${className}`}
>
{/* Gradient fade overlays */}
<div className="absolute left-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-r from-[#080808] to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-l from-[#080808] to-transparent z-10 pointer-events-none" />
<div className="absolute left-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-r from-[#28282C] to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-l from-[#28282C] to-transparent z-10 pointer-events-none" />
<div className="flex flex-col gap-4 h-full -mx-[280px] xl:-mx-8">
<div className="flex flex-col gap-5 h-full -mx-[280px] xl:-mx-8">
{rows.map((row, rowIdx) => {
const offset = rowOffsets[rowIdx] ?? 0;
return (
<div
key={rowIdx}
className="flex gap-4 whitespace-nowrap"
className="flex gap-5 whitespace-nowrap"
style={{ marginLeft: offset }}
>
{row.map(({ id, label, svg }, idx) => {
@@ -123,7 +123,7 @@ export default function ModelBadgeGrid({
className={twMerge(
baseBadgeClasses,
label ? "" : "min-w-[180px]",
isHighlight ? highlightClasses : "",
isHighlight ? highlightClasses : ""
)}
>
{svg ? (
@@ -8,14 +8,12 @@ import {
MenuItems,
Transition,
} from "@headlessui/react";
import * as NavigationMenu from "@radix-ui/react-navigation-menu";
import { twMerge } from "tailwind-merge";
import { useEffect, useState, Fragment } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu } from "@storyteller/ui-popover";
import { UsersApi, UserInfo, CreditsApi, BillingApi } from "@storyteller/api";
import { getSession, invalidateSession } from "../../lib/session";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCoins,
@@ -23,51 +21,21 @@ import {
faBars,
faXmark,
faGem,
faCog,
faArrowRight,
faChevronDown,
} from "@fortawesome/pro-solid-svg-icons";
import { TaskQueue } from "./task-queue";
import { CreditsModal } from "../credits-modal";
import { SettingsModal } from "../settings-modal/SettingsModal";
type NavLeaf = { name: string; href: string };
type NavGroup = { name: string; href?: string; children: NavLeaf[] };
type NavEntry = NavLeaf | NavGroup;
const NAV_ITEMS: NavEntry[] = [
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Image", href: "/create-image" },
{ name: "Video", href: "/create-video" },
{
name: "Resources",
children: [
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
],
},
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
{ name: "Download", href: "/download" },
];
function isGroup(entry: NavEntry): entry is NavGroup {
return "children" in entry && Array.isArray(entry.children);
}
function isPathActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
function isEntryActive(pathname: string, entry: NavEntry): boolean {
if (isGroup(entry)) {
if (entry.href && isPathActive(pathname, entry.href)) return true;
return entry.children.some((c) => isPathActive(pathname, c.href));
}
return isPathActive(pathname, entry.href);
}
async function fetchCredits(): Promise<number | null> {
try {
const api = new CreditsApi();
@@ -97,18 +65,27 @@ async function fetchHasPaidPlan(): Promise<boolean> {
}
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const location = useLocation();
const navigate = useNavigate();
// Pages with content flush to top need a solid navbar background always
const alwaysSolid =
location.pathname === "/create-image" ||
location.pathname === "/create-video" ||
location.pathname === "/library";
const [user, setUser] = useState<UserInfo | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [credits, setCredits] = useState<number | null>(null);
// null = unknown (still loading) so we don't flash the upgrade button
const [hasPaidPlan, setHasPaidPlan] = useState<boolean | null>(null);
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// Check session on mount and when auth changes or location changes
useEffect(() => {
const checkSession = async (force = false) => {
const response = await getSession(force);
const checkSession = async () => {
const api = new UsersApi();
const response = await api.GetSession();
if (
response.success &&
response.data &&
@@ -130,8 +107,7 @@ export default function Navbar() {
const handleAuthChange = () => {
setIsLoading(true);
invalidateSession();
checkSession(true);
checkSession();
};
const handleCreditsChange = () => {
@@ -149,519 +125,405 @@ export default function Navbar() {
const handleLogout = async () => {
const api = new UsersApi();
await api.Logout();
invalidateSession();
window.location.href = "/";
};
return (
<Disclosure
as="nav"
className="z-50 fixed top-0 left-0 w-full font-display"
>
{({ open }) => {
const isFullWidthRoute =
location.pathname.startsWith("/create-image") ||
location.pathname.startsWith("/create-video");
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 0);
};
return (
<div className="px-3 sm:px-5 pt-3">
<div
className={twMerge(
"liquid-glass mx-auto rounded-3xl transition-[max-width] duration-300",
isFullWidthRoute ? "max-w-none" : "max-w-6xl",
)}
>
<div className="flex h-11 sm:h-12 items-center justify-between pl-4 pr-2 sm:pl-5 sm:pr-2.5">
{/* Left: Logo + nav items */}
<div className="flex items-center gap-5 min-w-0">
<Link to="/" className="flex items-center shrink-0">
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<Disclosure as="nav" className="z-20 fixed top-0 left-0 w-full">
{({ open }) => (
<div
className={twMerge(
"transition-all duration-200",
scrolled || alwaysSolid || open ? "bg-[#1b1b1f]" : "bg-transparent",
)}
>
<div className="mx-auto max-w-screen px-4">
<div className="flex h-12 sm:h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
<Link to="/">
<img
alt="ArtCraft"
src="/images/artcraft-logo.png"
className="h-5 sm:h-5 w-auto"
className="h-5 sm:h-6 w-auto"
/>
</Link>
<NavigationMenu.Root
delayDuration={120}
className="hidden lg:flex items-center min-w-0"
>
<NavigationMenu.List className="flex items-center">
{NAV_ITEMS.map((entry) => {
const active = isEntryActive(location.pathname, entry);
const baseClasses =
"px-3 py-1.5 text-[13px] font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5";
const stateClasses = active
? "text-white bg-white/[0.08]"
: "text-white/60 hover:text-white hover:bg-white/[0.04]";
if (!isGroup(entry)) {
return (
<NavigationMenu.Item key={entry.name}>
<NavigationMenu.Link asChild>
<Link
to={entry.href}
aria-current={active ? "page" : undefined}
className={twMerge(baseClasses, stateClasses)}
>
{entry.name}
</Link>
</NavigationMenu.Link>
</NavigationMenu.Item>
);
}
return (
<NavigationMenu.Item
key={entry.name}
className="relative"
>
{entry.href ? (
<NavigationMenu.Trigger asChild>
<Link
to={entry.href}
aria-current={active ? "page" : undefined}
className={twMerge(
baseClasses,
stateClasses,
"group",
)}
>
{entry.name}
<FontAwesomeIcon
icon={faChevronDown}
className="text-[9px] transition-transform duration-200 group-data-[state=open]:rotate-180"
/>
</Link>
</NavigationMenu.Trigger>
) : (
<NavigationMenu.Trigger
className={twMerge(
baseClasses,
stateClasses,
"group focus:outline-none",
)}
>
{entry.name}
<FontAwesomeIcon
icon={faChevronDown}
className="text-[9px] transition-transform duration-200 group-data-[state=open]:rotate-180"
/>
</NavigationMenu.Trigger>
)}
<NavigationMenu.Content
className="absolute top-full left-0 mt-2 rounded-xl border border-white/[0.08] bg-[#1a1a1a] shadow-xl overflow-hidden"
>
<ul className="flex flex-col p-1.5 min-w-[180px]">
{entry.children.map((child) => {
const childActive = isPathActive(
location.pathname,
child.href,
);
return (
<li key={child.name}>
<NavigationMenu.Link asChild>
<Link
to={child.href}
aria-current={
childActive ? "page" : undefined
}
className={twMerge(
"block px-3 py-2 text-[13px] font-medium rounded-lg transition-colors whitespace-nowrap",
childActive
? "text-white bg-white/[0.08]"
: "text-white/70 hover:text-white hover:bg-white/[0.06]",
)}
>
{child.name}
</Link>
</NavigationMenu.Link>
</li>
);
})}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
);
})}
</NavigationMenu.List>
</NavigationMenu.Root>
</div>
{/* Right: Auth/credits/library */}
<div className="flex items-center gap-2 shrink-0">
{isLoading ? (
<div className="hidden md:flex items-center gap-2 opacity-0" />
) : user ? (
<div className="hidden md:flex items-center gap-2">
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
{NAV_ITEMS.map((item) => {
const isCurrent =
item.href === "/"
? location.pathname === "/"
: location.pathname === item.href ||
location.pathname.startsWith(item.href + "/");
return (
<Link
to="/pricing"
className="hidden xl:flex h-8 items-center gap-1.5 px-3 rounded-lg text-[13px] font-medium text-white/90 hover:text-white hover:bg-white/[0.04] transition-all"
key={item.name}
to={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
"nav-link",
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all",
)}
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
{credits !== null && (
<PopoverMenu
position="bottom"
align="center"
triggerIcon={
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[11px]"
/>
}
triggerLabel={
<span className="whitespace-nowrap text-[13px] font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl font-display"
>
{(close) => (
<div className="w-72 p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
setCreditsModalOpen(true);
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-medium text-white tracking-tight">
<FontAwesomeIcon
icon={faCoins}
className="text-xl text-primary"
/>
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
navigate("/pricing");
}}
icon={faGem}
>
Support
</Button>
</div>
</div>
<span className="relative z-10">{item.name}</span>
<span
className={twMerge(
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
)}
</PopoverMenu>
)}
{hasPaidPlan === false && (
<Button
variant="primary"
icon={faGem}
onClick={() => navigate("/pricing")}
className="h-8 px-3 text-[13px] font-semibold rounded-lg transition-all"
aria-hidden="true"
>
Support
</Button>
)}
<Link
to="/library"
className="flex h-8 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium text-white/80 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] transition-all"
>
<FontAwesomeIcon icon={faGrid2} className="text-[11px]" />
Library
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent ? "visible-line" : "",
)}
/>
</span>
</Link>
);
})}
</div>
</div>
<div className="flex items-center">
{isLoading ? (
<div className="hidden md:ml-4 md:flex items-center gap-2 opacity-0"></div>
) : user ? (
// ── Desktop: Logged In ─────────────────────────
<div className="hidden md:ml-4 md:flex items-center gap-2">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
{credits !== null && (
<PopoverMenu
position="bottom"
align="center"
triggerIcon={
<FontAwesomeIcon
icon={faCoins}
className="text-primary"
/>
}
triggerLabel={
<span className="whitespace-nowrap text-[15px] font-semibold">
{credits.toLocaleString()} Credits
</span>
}
buttonClassName="h-[34px] px-2 ps-1.5 bg-transparent hover:bg-white/10 border-0 shadow-none text-white/90 ms-2.5 me-1.5"
panelClassName="mt-3 bg-[#1C1C20] border border-white/10 text-white"
>
{(close) => (
<div className="w-72 p-2.5 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/80">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
onClick={() => {
close();
setCreditsModalOpen(true);
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-4xl font-bold text-white">
<FontAwesomeIcon
icon={faCoins}
className="text-2xl text-primary"
/>
{credits.toLocaleString()}
</div>
<TaskQueue />
<div className="mt-3 flex gap-2">
{/* <Button
variant="action"
className="h-9 grow"
onClick={() => {
close();
navigate("/pricing");
}}
>
See details
</Button> */}
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
navigate("/pricing");
}}
icon={faGem}
>
Support
</Button>
</div>
</div>
)}
</PopoverMenu>
)}
<Menu as="div" className="relative ml-1">
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
{hasPaidPlan === false && (
<Button
variant="primary"
icon={faGem}
onClick={() => navigate("/pricing")}
className="h-[38px] transition-all duration-300 hover:shadow-md hover:shadow-primary-600/75"
>
Support
</Button>
)}
<Link
to="/library"
className="flex h-[38px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-base-fg bg-ui-controls hover:bg-ui-controls/80 border border-ui-controls-border shadow-sm transition-all duration-150 active:scale-95"
>
<FontAwesomeIcon icon={faGrid2} className="text-xs" />
My Library
</Link>
<TaskQueue />
<Menu as="div" className="relative ml-3">
<div>
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
<span className="sr-only">Open user menu</span>
<img
className="h-full w-full object-cover"
className="h-8 w-8 rounded-full border border-white/10"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
modal={false}
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
>
<MenuItems
modal={false}
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/[0.06]">
<p className="text-sm font-medium text-white truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={() => setSettingsOpen(true)}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faCog}
className="text-[11px] text-white/50"
/>
Settings
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"block w-full text-left px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
) : (
<div className="hidden md:flex items-center gap-2">
<Link
to="/pricing"
className="h-8 flex items-center gap-1.5 px-3 rounded-lg text-[13px] font-medium text-white/90 hover:text-white hover:bg-white/[0.04] transition-all"
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
<Link
to="/login"
className="h-8 flex items-center px-3 rounded-lg text-[13px] font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
>
Login
</Link>
<Link
to="/signup"
className="group h-8 flex items-center gap-1.5 px-3.5 rounded-full text-[13px] font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
>
Sign up
<FontAwesomeIcon
icon={faArrowRight}
className="text-[10px] transition-transform group-hover:translate-x-0.5"
/>
</Link>
</div>
)}
{/* Mobile: hamburger + task queue */}
<div className="flex items-center gap-2 lg:hidden">
{user && <TaskQueue />}
<DisclosureButton className="flex h-8 w-8 items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors">
<span className="sr-only">Open main menu</span>
<FontAwesomeIcon
icon={open ? faXmark : faBars}
className="text-base"
/>
</DisclosureButton>
<div className="px-4 py-3 border-b border-white/10">
<p className="text-sm text-white font-semibold truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-white/5" : "",
"block w-full text-left px-4 py-2 text-sm text-white/70",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
) : (
// ── Desktop: Logged Out ────────────────────────
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
<div className="flex gap-2">
<Link to="/login">
<Button
variant="primary"
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
)}
{/* ── Mobile: hamburger + task queue ──────────── */}
<div className="flex items-center gap-2 md:hidden">
{user && <TaskQueue />}
<DisclosureButton className="flex h-9 w-9 items-center justify-center rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-colors">
<span className="sr-only">Open main menu</span>
<FontAwesomeIcon
icon={open ? faXmark : faBars}
className="text-lg"
/>
</DisclosureButton>
</div>
</div>
{/* Mobile slide-down panel */}
<Transition
as={Fragment}
enter="transition duration-150 ease-out"
enterFrom="opacity-0 -translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition duration-100 ease-in"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-1"
>
<DisclosurePanel className="lg:hidden border-t border-white/[0.06] px-3 pb-3 pt-2">
<div className="flex flex-col">
{NAV_ITEMS.map((entry) => {
if (!isGroup(entry)) {
const isCurrent = isPathActive(
location.pathname,
entry.href,
);
return (
<DisclosureButton
key={entry.name}
as={Link}
to={entry.href}
className={twMerge(
"rounded-lg px-3 py-2 text-[13px] font-medium transition-colors",
isCurrent
? "bg-white/[0.08] text-white"
: "text-white/60 active:bg-white/[0.04]",
)}
>
{entry.name}
</DisclosureButton>
);
}
const headerActive =
entry.href !== undefined &&
isPathActive(location.pathname, entry.href);
return (
<div key={entry.name} className="flex flex-col">
{entry.href ? (
<DisclosureButton
as={Link}
to={entry.href}
className={twMerge(
"rounded-lg px-3 py-2 text-[13px] font-medium transition-colors",
headerActive
? "bg-white/[0.08] text-white"
: "text-white/60 active:bg-white/[0.04]",
)}
>
{entry.name}
</DisclosureButton>
) : (
<div className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
{entry.name}
</div>
)}
<div className="flex flex-col pl-3">
{entry.children.map((child) => {
const childActive = isPathActive(
location.pathname,
child.href,
);
return (
<DisclosureButton
key={child.name}
as={Link}
to={child.href}
className={twMerge(
"rounded-lg px-3 py-2 text-[13px] font-medium transition-colors",
childActive
? "bg-white/[0.08] text-white"
: "text-white/55 active:bg-white/[0.04]",
)}
>
{child.name}
</DisclosureButton>
);
})}
</div>
</div>
);
})}
</div>
<div className="my-3 border-t border-white/[0.06]" />
{!isLoading && user ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5">
<DisclosureButton
as={Link}
to="/library"
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium text-white/70 bg-white/[0.06] active:bg-white/10 transition-colors"
>
<FontAwesomeIcon
icon={faGrid2}
className="text-[10px]"
/>
Library
</DisclosureButton>
<DisclosureButton
as={Link}
to="/pricing"
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium text-white/70 bg-white/[0.06] active:bg-white/10 transition-colors"
>
<FontAwesomeIcon icon={faGem} className="text-[10px]" />
Pricing
</DisclosureButton>
<DisclosureButton
as="button"
onClick={() => setSettingsOpen(true)}
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium text-white/70 bg-white/[0.06] active:bg-white/10 transition-colors"
>
<FontAwesomeIcon icon={faCog} className="text-[10px]" />
Settings
</DisclosureButton>
{credits !== null && (
<span className="flex items-center gap-1.5 ml-auto text-[12px] font-medium text-white/80">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[10px]"
/>
{credits.toLocaleString()}
</span>
)}
</div>
<div className="flex items-center gap-2">
<img
className="h-7 w-7 rounded-full border border-white/[0.08] shrink-0"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
<span className="truncate text-[13px] font-medium text-white/80 flex-1">
{user.display_name || user.username}
</span>
<DisclosureButton
as="button"
onClick={handleLogout}
className="flex h-7 items-center rounded-lg px-2.5 text-[12px] font-medium text-red-400/80 active:bg-red-500/10 transition-colors shrink-0"
>
Sign out
</DisclosureButton>
</div>
</div>
) : !isLoading ? (
<div className="flex gap-2">
<DisclosureButton as={Link} to="/login" className="flex-1">
<button className="w-full h-9 rounded-lg text-[13px] font-semibold text-white/80 bg-white/[0.06] active:bg-white/10 transition-colors">
Login
</button>
</DisclosureButton>
<DisclosureButton as={Link} to="/signup" className="flex-1">
<button className="w-full h-9 rounded-lg text-[13px] font-semibold text-black bg-white active:bg-white/90 transition-colors">
Sign up
</button>
</DisclosureButton>
</div>
) : null}
</DisclosurePanel>
</Transition>
</div>
<CreditsModal
isOpen={creditsModalOpen}
onClose={() => setCreditsModalOpen(false)}
/>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</div>
);
}}
{/* ── Mobile slide-down panel ─────────────────────── */}
<Transition
as={Fragment}
enter="transition duration-150 ease-out"
enterFrom="opacity-0 -translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transition duration-100 ease-in"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2"
>
<DisclosurePanel className="md:hidden border-t border-white/10 bg-[#1b1b1f]/95 backdrop-blur-xl">
<div className="px-3 pb-3 pt-3">
{/* Nav links */}
<div className="flex flex-col">
{NAV_ITEMS.map((item) => {
const isCurrent =
item.href === "/"
? location.pathname === "/"
: location.pathname === item.href ||
location.pathname.startsWith(item.href + "/");
return (
<DisclosureButton
key={item.name}
as={Link}
to={item.href}
className={twMerge(
"rounded-md px-3 py-[7px] text-[13px] font-medium transition-colors",
isCurrent
? "bg-white/10 text-white"
: "text-white/60 active:bg-white/5",
)}
>
{item.name}
</DisclosureButton>
);
})}
</div>
<div className="my-3 border-t border-white/[0.06]" />
{!isLoading && user ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5">
<DisclosureButton
as={Link}
to="/library"
className="flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium text-white/60 bg-white/[0.06] active:bg-white/10 transition-colors"
>
<FontAwesomeIcon
icon={faGrid2}
className="text-[10px]"
/>
Library
</DisclosureButton>
<DisclosureButton
as={Link}
to="/pricing"
className="flex h-8 items-center rounded-md px-2.5 text-[12px] font-medium text-white/60 bg-white/[0.06] active:bg-white/10 transition-colors"
>
Pricing
</DisclosureButton>
{credits !== null && (
<span className="flex items-center gap-2 ml-auto text-[12px] font-medium text-white/80">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[10px]"
/>
{credits.toLocaleString()} Credits
</span>
)}
</div>
<div className="flex items-center gap-2">
<img
className="h-7 w-7 rounded-full border border-white/10 shrink-0"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
<span className="truncate text-[13px] font-medium text-white/80 flex-1">
{user.display_name || user.username}
</span>
<DisclosureButton
as="button"
onClick={handleLogout}
className="flex h-7 items-center rounded-md px-2.5 text-[12px] font-medium text-red-400/70 active:bg-red-500/10 transition-colors shrink-0"
>
Sign out
</DisclosureButton>
</div>
</div>
) : !isLoading ? (
<div className="flex gap-2">
<DisclosureButton as={Link} to="/login" className="flex-1">
<Button className="w-full bg-white text-black hover:bg-white/90 text-[13px] font-semibold h-9 rounded-lg justify-center">
Login
</Button>
</DisclosureButton>
<DisclosureButton as={Link} to="/signup" className="flex-1">
<Button
variant="primary"
className="w-full text-[13px] font-semibold h-9 rounded-lg justify-center"
>
Sign up
</Button>
</DisclosureButton>
</div>
) : null}
</div>
</DisclosurePanel>
</Transition>
<CreditsModal
isOpen={creditsModalOpen}
onClose={() => setCreditsModalOpen(false)}
/>
<style>{`
.link-underline {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
transition: transform 220ms ease, opacity 220ms ease;
}
.nav-link:hover .link-underline {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.visible-line {
transform: scaleX(1) translateX(0);
opacity: 1;
}
`}</style>
</div>
)}
</Disclosure>
);
}
@@ -17,8 +17,7 @@ import {
import { Modal } from "@storyteller/ui-modal";
import { useEffect, useMemo, useRef, useState } from "react";
import { JobsApi, JobStatus } from "@storyteller/api";
import type { Job, Prompts } from "@storyteller/api";
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
import type { Job } from "@storyteller/api";
import { Button } from "@storyteller/ui-button";
import {
getProviderDisplayName,
@@ -31,7 +30,6 @@ import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { Lightbox } from "../lightbox/lightbox";
import { showToast } from "../toast/toast";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import { twMerge } from "tailwind-merge";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -157,10 +155,10 @@ const PromptLine = ({
style={
marqueePlaying
? {
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
: undefined
}
>
@@ -462,29 +460,13 @@ const FailedCard = ({
// ── Job → Task transformers ────────────────────────────────────────────────
function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
const promptToken = job.request.maybe_prompt_token;
const cached = promptToken
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
return (
cached?.maybe_positive_prompt ||
job.request.maybe_raw_inference_text ||
""
);
function getPrompt(job: Job): string {
return job.request.maybe_raw_inference_text || "";
}
function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
const promptToken = job.request.maybe_prompt_token;
const cachedPrompt = promptToken
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
function formatTitleParts(job: Job) {
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
const modelTypeStr =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
const providerKey =
cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
const modelTypeStr = job.request.maybe_model_type ?? "";
let kind: string | undefined;
if (taskTypeStr.includes("video")) {
@@ -498,25 +480,22 @@ function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
const modelDisplay = modelTypeStr
? getModelDisplayName(modelTypeStr)
: undefined;
const provider = providerKey
? getProviderDisplayName(providerKey.toLowerCase())
const provider = modelTypeStr
? getProviderDisplayName(modelTypeStr.toLowerCase())
: undefined;
const title = kind || "Task";
const subtitle =
modelDisplay && provider
? `${modelDisplay} · ${provider}`
: modelDisplay || provider || undefined;
? `${modelDisplay} — ${provider}`
: modelDisplay || job.request.maybe_model_title || undefined;
return { title, subtitle, kind };
}
// Cache per-task durations so model changes don't affect existing progress bars
const taskDurationCache = new Map<string, number>();
function jobsToInProgress(
jobs: Job[],
promptsMap: Map<string, Prompts>,
): InProgressTask[] {
function jobsToInProgress(jobs: Job[]): InProgressTask[] {
const now = Date.now();
const activeIds = new Set<string>();
@@ -529,7 +508,7 @@ function jobsToInProgress(
.map((j): InProgressTask => {
activeIds.add(j.job_token);
const createdMs = new Date(j.created_at).getTime();
const parts = formatTitleParts(j, promptsMap);
const parts = formatTitleParts(j);
const isVideo =
j.request.inference_category?.toLowerCase().includes("video") ?? false;
const modelType = j.request.maybe_model_type;
@@ -539,8 +518,8 @@ function jobsToInProgress(
if (!duration) {
const model = modelType
? ALL_MODELS_LIST.find(
(m) => m.tauriId === modelType || m.id === modelType,
)
(m) => m.tauriId === modelType || m.id === modelType,
)
: undefined;
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
taskDurationCache.set(j.job_token, duration);
@@ -562,7 +541,7 @@ function jobsToInProgress(
canDismiss,
estimatedTimeLeftMs,
modelType: modelType ?? undefined,
prompt: getPrompt(j, promptsMap) || undefined,
prompt: getPrompt(j) || undefined,
};
});
@@ -574,10 +553,7 @@ function jobsToInProgress(
return result;
}
function jobsToCompleted(
jobs: Job[],
promptsMap: Map<string, Prompts>,
): CompletedTask[] {
function jobsToCompleted(jobs: Job[]): CompletedTask[] {
return jobs
.filter((j) => COMPLETED_STATUSES.has(j.status.status))
.sort(
@@ -586,22 +562,13 @@ function jobsToCompleted(
)
.map((j): CompletedTask => {
const cdnUrl = j.maybe_result?.media_links?.cdn_url;
const mediaClass = j.request.inference_category
?.toLowerCase()
.includes("video")
? "video"
: "image";
const thumbnailUrl =
getMediaThumbnail(j.maybe_result?.media_links, mediaClass, {
size: THUMBNAIL_SIZES.MEDIUM,
}) ?? cdnUrl;
const parts = formatTitleParts(j, promptsMap);
const parts = formatTitleParts(j);
return {
id: j.job_token,
title: parts.title,
subtitle: parts.subtitle,
thumbnailUrl,
thumbnailUrl: cdnUrl,
completedAt: j.maybe_result?.maybe_successfully_completed_at
? new Date(j.maybe_result.maybe_successfully_completed_at)
: new Date(j.updated_at),
@@ -610,15 +577,12 @@ function jobsToCompleted(
mediaTokens: j.maybe_result?.entity_token
? [j.maybe_result.entity_token]
: [],
prompt: getPrompt(j, promptsMap) || undefined,
prompt: getPrompt(j) || undefined,
};
});
}
function jobsToFailed(
jobs: Job[],
promptsMap: Map<string, Prompts>,
): FailedTask[] {
function jobsToFailed(jobs: Job[]): FailedTask[] {
return jobs
.filter((j) => FAILED_STATUSES.has(j.status.status))
.sort(
@@ -626,7 +590,7 @@ function jobsToFailed(
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
)
.map((j): FailedTask => {
const parts = formatTitleParts(j, promptsMap);
const parts = formatTitleParts(j);
const failureCategory =
j.status.maybe_failure_category_updated ||
j.status.maybe_failure_category;
@@ -647,7 +611,7 @@ function jobsToFailed(
status: j.status.status,
failureReason,
failureMessage,
prompt: getPrompt(j, promptsMap) || undefined,
prompt: getPrompt(j) || undefined,
};
});
}
@@ -656,32 +620,12 @@ function jobsToFailed(
export const TaskQueue = () => {
const [isModalOpen, setModalOpen] = useState(false);
const [jobs, setJobs] = useState<Job[]>([]);
const [inProgress, setInProgress] = useState<InProgressTask[]>([]);
const [completed, setCompleted] = useState<CompletedTask[]>([]);
const [failed, setFailed] = useState<FailedTask[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [unreadCompletedIds, setUnreadCompletedIds] = useState<string[]>([]);
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const j of jobs) {
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
}
return tokens;
}, [jobs]);
const promptsMap = usePrompts(promptTokens);
const inProgress = useMemo(
() => jobsToInProgress(jobs, promptsMap),
[jobs, promptsMap],
);
const completed = useMemo(
() => jobsToCompleted(jobs, promptsMap),
[jobs, promptsMap],
);
const failed = useMemo(
() => jobsToFailed(jobs, promptsMap),
[jobs, promptsMap],
);
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxMediaToken, setLightboxMediaToken] = useState<
@@ -710,7 +654,7 @@ export const TaskQueue = () => {
primaryActionText: "",
primaryActionIcon: faTrashAlt,
primaryActionBtnClassName: "",
onConfirm: async () => { },
onConfirm: async () => {},
});
// ── Confirmation handlers ──────────────────────────────────────────
@@ -803,19 +747,19 @@ export const TaskQueue = () => {
const response = await api.ListRecentJobs();
if (cancelled || !response.success || !response.data) return;
const fetchedJobs: Job[] = response.data;
setJobs(fetchedJobs);
const jobs: Job[] = response.data;
// Toast titles use formatTitleParts directly so we don't depend on
// the prompt cache being populated here.
const completedJobs = fetchedJobs.filter((j) =>
COMPLETED_STATUSES.has(j.status.status),
);
const failedJobs = fetchedJobs.filter((j) =>
FAILED_STATUSES.has(j.status.status),
);
const newCompletedIdSet = new Set(completedJobs.map((j) => j.job_token));
const newFailedIdSet = new Set(failedJobs.map((j) => j.job_token));
const inProg = jobsToInProgress(jobs);
const done = jobsToCompleted(jobs);
const failedTasks = jobsToFailed(jobs);
setInProgress(inProg);
setCompleted(done);
setFailed(failedTasks);
// Track newly completed IDs
const newCompletedIdSet = new Set(done.map((d) => d.id));
const newFailedIdSet = new Set(failedTasks.map((f) => f.id));
if (!initialLoadDoneRef.current) {
// First load: seed the "seen" sets without toasting.
@@ -823,47 +767,36 @@ export const TaskQueue = () => {
prevFailedIdsRef.current = newFailedIdSet;
initialLoadDoneRef.current = true;
} else {
const newlyCompletedJobs = completedJobs.filter(
(j) => !prevCompletedIdsRef.current.has(j.job_token),
const newlyCompleted = done.filter(
(d) => !prevCompletedIdsRef.current.has(d.id),
);
prevCompletedIdsRef.current = newCompletedIdSet;
if (newlyCompletedJobs.length > 0) {
for (const job of newlyCompletedJobs) {
const parts = formatTitleParts(job);
showToast("success", `${parts.title} creation complete`);
if (newlyCompleted.length > 0) {
for (const task of newlyCompleted) {
showToast("success", `${task.title} creation complete`);
}
if (!isPopoverOpen) {
setUnreadCompletedIds((prev) =>
Array.from(
new Set([
...(prev ?? []),
...newlyCompletedJobs.map((j) => j.job_token),
...newlyCompleted.map((d) => d.id),
]),
),
);
}
}
const newlyFailedJobs = failedJobs.filter(
(j) => !prevFailedIdsRef.current.has(j.job_token),
const newlyFailed = failedTasks.filter(
(f) => !prevFailedIdsRef.current.has(f.id),
);
prevFailedIdsRef.current = newFailedIdSet;
for (const job of newlyFailedJobs) {
const parts = formatTitleParts(job);
const failureCategory =
job.status.maybe_failure_category_updated ||
job.status.maybe_failure_category;
const rawMessage =
job.status.maybe_failure_message ||
job.status.maybe_extra_status_description;
const failureReason = failureCategory
? FAILURE_REASON_LABEL[failureCategory] || rawMessage || undefined
: rawMessage || undefined;
for (const task of newlyFailed) {
showToast(
"error",
`${parts.title} creation failed${failureReason ? ` — ${failureReason}` : ""}`,
`${task.title} creation failed${task.failureReason ? ` — ${task.failureReason}` : ""}`,
);
}
}
@@ -911,7 +844,9 @@ export const TaskQueue = () => {
try {
const api = new JobsApi();
await api.DeleteJobByToken(id);
setJobs((prev) => prev.filter((j) => j.job_token !== id));
setInProgress((prev) => prev.filter((t) => t.id !== id));
setCompleted((prev) => prev.filter((t) => t.id !== id));
setFailed((prev) => prev.filter((t) => t.id !== id));
setUnreadCompletedIds((prev) => (prev ?? []).filter((x) => x !== id));
} catch {
// ignore
@@ -927,8 +862,7 @@ export const TaskQueue = () => {
} catch {
// ignore
} finally {
const idSet = new Set(ids);
setJobs((prev) => prev.filter((j) => !idSet.has(j.job_token)));
setCompleted([]);
setUnreadCompletedIds([]);
}
};
@@ -942,8 +876,7 @@ export const TaskQueue = () => {
} catch {
// ignore
} finally {
const idSet = new Set(ids);
setJobs((prev) => prev.filter((j) => !idSet.has(j.job_token)));
setFailed([]);
}
};
@@ -953,8 +886,7 @@ export const TaskQueue = () => {
await Promise.allSettled(
staleIds.map((id) => new JobsApi().DeleteJobByToken(id)),
);
const idSet = new Set(staleIds);
setJobs((prev) => prev.filter((j) => !idSet.has(j.job_token)));
setInProgress((prev) => prev.filter((t) => !staleIds.includes(t.id)));
} catch {
// ignore
}
@@ -973,7 +905,9 @@ export const TaskQueue = () => {
} catch {
// ignore
} finally {
setJobs([]);
setInProgress([]);
setCompleted([]);
setFailed([]);
setUnreadCompletedIds([]);
}
};
@@ -1072,24 +1006,24 @@ export const TaskQueue = () => {
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative task-queue-trigger">
{badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary-400 px-1 text-[10px] font-semibold text-white ring-2 ring-[#121212]">
<div className="absolute -right-1 -top-1 z-20 flex h-[17px] w-[17px] items-center justify-center rounded-full bg-primary-400 text-[13px] font-medium text-white">
{badgeCount}
</div>
)}
<PopoverMenu
mode="default"
buttonClassName="h-8 w-8 !p-0 relative bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/80 rounded-lg shadow-none"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#1a1a1a] border border-white/[0.08] mt-2 rounded-xl font-display"
buttonClassName="h-[38px] w-[38px] !p-0 relative"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-ui-panel mt-2.5"
position="bottom"
align="end"
triggerIcon={
inProgressCount > 0 ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-[11px]"
className="animate-spin"
/>
) : (
<FontAwesomeIcon icon={faListCheck} className="text-[11px]" />
<FontAwesomeIcon icon={faListCheck} />
)
}
onOpenChange={handleOpenChange}
@@ -1131,28 +1065,28 @@ export const TaskQueue = () => {
<h2 className="text-lg font-semibold">Task Queue</h2>
<div className="flex items-center gap-2">
<Button
className="rounded-full flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
className="flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
onClick={() => handleClearCompleted()}
>
<FontAwesomeIcon icon={faBroom} className="mr-1.5" />
Clear completed
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
className="flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
onClick={() => handleClearStale()}
>
<FontAwesomeIcon icon={faTrashAlt} className="mr-1.5" />
Clear stale
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
onClick={() => handleClearFailed()}
>
<FontAwesomeIcon icon={faTrashAlt} className="mr-1.5" />
Clear failed
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
onClick={() => handleRemoveAll()}
>
<FontAwesomeIcon icon={faBomb} className="mr-1.5" />
@@ -39,9 +39,6 @@ interface PricingTableProps {
compact?: boolean;
showSeedanceFeatures?: boolean;
showEnterprise?: boolean;
/** Use the unified landing3-style monochrome theme instead of per-plan
* green/purple/orange color schemes. */
unifiedTheme?: boolean;
}
const PricingTable = ({
@@ -53,7 +50,6 @@ const PricingTable = ({
compact = false,
showSeedanceFeatures = false,
showEnterprise = false,
unifiedTheme = false,
}: PricingTableProps) => {
const navigate = useNavigate();
const [billingType, setBillingType] = useState("yearly");
@@ -116,17 +112,14 @@ const PricingTable = ({
const getColorSchemeClasses = (
colorScheme: SubscriptionPlanDetails["colorScheme"],
) => {
const baseClasses = unifiedTheme
? "relative rounded-2xl sm:rounded-[28px] p-5 md:p-6 border flex flex-col transition-all duration-300"
: "relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
const baseClasses =
"relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
switch (colorScheme) {
case "dark":
return twMerge(
baseClasses,
unifiedTheme
? "bg-[#080808] border-white/[0.08] hover:border-white/15"
: "bg-[#1C1C20] border-white/10 hover:border-white/20",
"bg-[#1C1C20] border-white/10 hover:border-white/20",
);
case "green":
return twMerge(
@@ -339,9 +332,7 @@ const PricingTable = ({
</span>
</div>
<div
className={`${unifiedTheme ? "max-w-6xl" : "max-w-7xl"} mx-auto grid ${gridCols} gap-4 md:gap-6`}
>
<div className={`max-w-7xl mx-auto grid ${gridCols} gap-4 md:gap-6`}>
{plans.map((plan) => {
const isPopular = plan.slug === "artcraft_pro";
const isCurrent = isCurrentPlan(plan.slug);
@@ -358,14 +349,8 @@ const PricingTable = ({
}
>
{isPopular && !isCurrent && (
<div
className={
unifiedTheme
? "absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center text-xs font-semibold text-primary-200 bg-primary/[0.18] border border-primary/30 rounded-full px-3 py-1 backdrop-blur whitespace-nowrap"
: "absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap"
}
>
Most Popular
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
MOST POPULAR
</div>
)}
@@ -451,13 +436,7 @@ const PricingTable = ({
{/* Enterprise card - inline in grid for normal view */}
{showEnterprise && !compact && (
<div
className={
unifiedTheme
? "relative rounded-2xl sm:rounded-[28px] p-5 md:p-6 border flex flex-col transition-all duration-300 bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
: "relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
}
>
<div className="relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]">
<h3 className="text-xl md:text-2xl font-bold mb-2 text-white">
Enterprise
</h3>
@@ -489,13 +468,7 @@ const PricingTable = ({
{/* Enterprise card - horizontal bar below grid for compact/sd2 view */}
{showEnterprise && compact && (
<div
className={
unifiedTheme
? "mt-4 relative rounded-2xl sm:rounded-[28px] p-6 border flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all duration-300 bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
: "mt-4 relative rounded-3xl p-6 border flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
}
>
<div className="mt-4 relative rounded-3xl p-6 border flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]">
<div className="flex-shrink-0">
<h3 className="text-lg font-bold text-white">Enterprise</h3>
<div className="text-2xl font-bold mt-1">Custom</div>
@@ -17,7 +17,7 @@ import {
Character,
MediaUploadApi,
} from "@storyteller/api";
import { toast } from "../toast/toast";
import { toast } from "@storyteller/ui-toaster";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore } from "./characters-store";
@@ -39,11 +39,9 @@ interface UploadedImage {
interface PendingCharacter {
name: string;
previewUrl?: string;
createdAt: number;
}
const POLL_INTERVAL_MS = 5000;
const PENDING_STALE_MS = 3 * 60 * 1000;
export const CharactersModal = ({
isOpen,
@@ -76,11 +74,8 @@ export const CharactersModal = ({
setRefreshKey((k) => k + 1);
};
const handleCreated = (pending: { name: string; previewUrl?: string }) => {
setPendingCharacters((prev) => [
{ ...pending, createdAt: Date.now() },
...prev,
]);
const handleCreated = (pending: PendingCharacter) => {
setPendingCharacters((prev) => [pending, ...prev]);
setView("list");
setRefreshKey((k) => k + 1);
};
@@ -89,58 +84,6 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
useEffect(() => {
if (!isOpen || pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListAllCharacters();
if (!res.success || !res.data) return;
const serverNames = new Set(res.data.map((c) => c.name));
let resolved = false;
setPendingCharacters((prev) =>
prev.filter((p) => {
if (serverNames.has(p.name)) {
resolved = true;
return false;
}
return true;
}),
);
if (resolved) setRefreshKey((k) => k + 1);
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isOpen, pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
if (pendingCharacters.length === 0) return;
const timers = pendingCharacters.map((pending) =>
setTimeout(
() => {
setPendingCharacters((prev) => {
if (!prev.some((p) => p.name === pending.name)) return prev;
toast.error(`Character "${pending.name}" failed to create`);
return prev.filter((p) => p.name !== pending.name);
});
},
Math.max(0, PENDING_STALE_MS - (Date.now() - pending.createdAt)),
),
);
return () => timers.forEach(clearTimeout);
}, [pendingCharacters]);
return (
<Modal
isOpen={isOpen}
@@ -205,9 +148,13 @@ const CharacterListView = ({
}) => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<number | undefined>(undefined);
const [confirmDelete, setConfirmDelete] = useState<Character | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
const storeRemoveCharacter = useCharactersStore((s) => s.removeCharacter);
@@ -226,33 +173,79 @@ const CharacterListView = ({
[storeSetCharacters, storeSetLoaded],
);
const fetchCharacters = useCallback(async () => {
try {
const api = new CharactersApi();
const res = await api.ListAllCharacters();
if (res.success && res.data) {
setCharacters(res.data);
syncToStore(res.data);
const fetchCharacters = useCallback(
async (nextCursor?: number) => {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
try {
const api = new CharactersApi();
const res = await api.ListCharacters({ cursor: nextCursor });
if (res.success && res.data) {
setCharacters((prev) => {
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
syncToStore(updated);
return updated;
});
const nextPage = res.pagination?.next_cursor;
setCursor(nextPage ?? undefined);
setHasMore(!!nextPage);
}
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
loadingMoreRef.current = false;
}
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
}
}, [syncToStore, storeSetLoaded]);
},
[syncToStore, storeSetLoaded],
);
useEffect(() => {
fetchCharacters();
}, [fetchCharacters]);
// Drop pendings already represented in the fetched list (no duplicate cards)
// Poll for pending characters becoming active
useEffect(() => {
if (pendingCharacters.length === 0) return;
const names = new Set(characters.map((c) => c.name));
for (const pending of pendingCharacters) {
if (names.has(pending.name)) onPendingResolved(pending.name);
}
}, [characters, pendingCharacters, onPendingResolved]);
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListCharacters({});
if (res.success && res.data) {
const serverNames = new Set(res.data.map((c) => c.name));
for (const pending of pendingCharacters) {
if (serverNames.has(pending.name)) {
onPendingResolved(pending.name);
}
}
setCharacters(res.data);
syncToStore(res.data);
}
} catch {
// Silently retry on next interval
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [pendingCharacters, onPendingResolved, syncToStore]);
// Infinite scroll via IntersectionObserver
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && cursor) {
fetchCharacters(cursor);
}
},
{ threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, cursor, fetchCharacters]);
const handleDelete = async (character: Character) => {
setIsDeleting(true);
@@ -430,6 +423,16 @@ const CharacterListView = ({
</div>
)}
{/* Sentinel for infinite scroll */}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-4">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-white/30 animate-spin"
/>
</div>
)}
{/* Delete confirmation overlay */}
{confirmDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
@@ -608,7 +611,7 @@ const NewCharacterView = ({
onCreated,
}: {
onBack: () => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
onCreated: (pending: PendingCharacter) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
@@ -11,7 +11,6 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import { toast } from "../toast/toast";
import type { RefVideo, RefAudio } from "./types";
import {
uploadVideo,
@@ -48,6 +47,11 @@ export const MediaReferenceRow = ({
const [uploadingVideo, setUploadingVideo] = useState(false);
const [uploadingAudio, setUploadingAudio] = useState(false);
const referenceVideosRef = useRef(referenceVideos);
referenceVideosRef.current = referenceVideos;
const referenceAudiosRef = useRef(referenceAudios);
referenceAudiosRef.current = referenceAudios;
const totalVideoDuration = referenceVideos.reduce(
(sum, v) => sum + v.duration,
0,
@@ -57,42 +61,24 @@ export const MediaReferenceRow = ({
0,
);
const canAddVideo =
referenceVideos.length < maxVideoCount &&
totalVideoDuration < maxVideoRefDuration &&
!uploadingVideo;
const canAddAudio =
referenceAudios.length < maxAudioCount &&
totalAudioDuration < maxAudioRefDuration &&
!uploadingAudio;
const canAddVideo = referenceVideos.length < maxVideoCount && !uploadingVideo;
const canAddAudio = referenceAudios.length < maxAudioCount && !uploadingAudio;
const handleVideoUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(e.target.files || []);
if (videoInputRef.current) videoInputRef.current.value = "";
if (files.length === 0) return;
const handleVideoUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
// Snapshot at call time so a stale read inside the async callback can't overwrite a remove.
const baseVideos = [...referenceVideos];
const file = files[0];
const duration = await getVideoDuration(file);
const file = files[0];
const duration = await getVideoDuration(file);
if (totalVideoDuration + duration > maxVideoRefDuration) {
if (videoInputRef.current) videoInputRef.current.value = "";
return;
}
if (duration <= 0) {
toast.error("Could not read video file");
return;
}
const currentTotal = baseVideos.reduce((sum, v) => sum + v.duration, 0);
if (currentTotal + duration > maxVideoRefDuration) {
toast.error(
`Video too long — max ${maxVideoRefDuration}s total (${maxVideoRefDuration - currentTotal}s remaining)`,
);
return;
}
setUploadingVideo(true);
setUploadingVideo(true);
try {
await uploadVideo({
title: `reference-video-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
@@ -105,50 +91,37 @@ export const MediaReferenceRow = ({
mediaToken: newState.data,
duration,
};
onReferenceVideosChange([...baseVideos, refVideo]);
onReferenceVideosChange([...referenceVideosRef.current, refVideo]);
setUploadingVideo(false);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
toast.error(newState.errorMessage || "Could not upload video");
setUploadingVideo(false);
}
},
});
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Could not upload video",
);
} finally {
setUploadingVideo(false);
}
};
const handleAudioUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(e.target.files || []);
if (audioInputRef.current) audioInputRef.current.value = "";
if (files.length === 0) return;
if (videoInputRef.current) videoInputRef.current.value = "";
},
[totalVideoDuration, maxVideoRefDuration, onReferenceVideosChange],
);
const baseAudios = [...referenceAudios];
const handleAudioUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
const file = files[0];
const duration = await getAudioDuration(file);
const file = files[0];
const duration = await getAudioDuration(file);
if (duration <= 0) {
toast.error("Could not read audio file");
return;
}
const currentTotal = baseAudios.reduce((sum, a) => sum + a.duration, 0);
if (currentTotal + duration > maxAudioRefDuration) {
toast.error(
`Audio too long — max ${maxAudioRefDuration}s total (${maxAudioRefDuration - currentTotal}s remaining)`,
);
return;
}
if (totalAudioDuration + duration > maxAudioRefDuration) {
if (audioInputRef.current) audioInputRef.current.value = "";
return;
}
setUploadingAudio(true);
setUploadingAudio(true);
try {
await uploadAudio({
title: `reference-audio-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
@@ -161,23 +134,21 @@ export const MediaReferenceRow = ({
mediaToken: newState.data,
duration,
};
onReferenceAudiosChange([...baseAudios, refAudio]);
onReferenceAudiosChange([...referenceAudiosRef.current, refAudio]);
setUploadingAudio(false);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
toast.error(newState.errorMessage || "Could not upload audio");
setUploadingAudio(false);
}
},
});
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Could not upload audio",
);
} finally {
setUploadingAudio(false);
}
};
if (audioInputRef.current) audioInputRef.current.value = "";
},
[totalAudioDuration, maxAudioRefDuration, onReferenceAudiosChange],
);
const removeVideo = (id: string) => {
const video = referenceVideos.find((v) => v.id === id);
@@ -529,13 +529,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const el = editorRef.current;
if (!el) return;
// Use the known mention bounds — `getCaretOffset` can return 0 when the
// dropdown click collapses the selection out of the editor, which would
// leave the typed query in the prompt alongside the inserted label.
const queryEnd =
mentionState.triggerIndex + mentionState.query.length;
const caretPos = getCaretOffset(el);
const before = value.slice(0, mentionState.triggerIndex);
const after = value.slice(queryEnd);
const after = value.slice(caretPos);
const mention = `${item.label} `;
const newValue = before + mention + after;
@@ -554,7 +550,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
el.focus();
});
},
[value, mentionState.triggerIndex, mentionState.query, onChange],
[value, mentionState.triggerIndex, onChange],
);
const handleKeyDown = useCallback(
@@ -591,7 +587,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}
}
if (e.key === "Enter") {
// Default: plain Enter inserts a newline (instead of letting the
// contentEditable create a <div>). Shift/Cmd+Enter falls through to
// externalOnKeyDown so the host can submit.
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
e.preventDefault();
document.execCommand("insertLineBreak");
handleInput();
@@ -21,7 +21,6 @@ import { Tooltip } from "@storyteller/ui-tooltip";
import { ImagePromptRow } from "./ImagePromptRow";
import { MentionTextarea } from "./MentionTextarea";
import type { RefImage, MentionItem } from "./types";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
// ── @-mention color palette ─────────────────────────────────────────────
@@ -145,7 +144,6 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const mentionEditorRef = useRef<HTMLDivElement>(null);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [showImagePrompts, setShowImagePrompts] = useState(false);
@@ -293,7 +291,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}
}
if (e.key === "Enter" && enterToGenerate && !e.shiftKey) {
// Default: Enter inserts a newline; Shift+Enter submits.
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
onSubmit();
}
@@ -363,7 +362,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}, [prompt, hasMentionItems, mentionRegex, mentionLabelMap, mentionItems]);
return (
<div ref={ref} className="prompt-box-root font-display">
<div ref={ref}>
<div className="relative flex flex-col">
{isImageRowVisible && (
<ImagePromptRow
@@ -437,7 +436,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
)}
colorMap={mentionColorMap}
onKeyDown={(e) => {
if (e.key === "Enter" && enterToGenerate && !e.shiftKey && !e.metaKey) {
// Default: Enter inserts a newline; Shift+Enter (or Cmd+Enter) submits.
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
e.preventDefault();
onSubmit();
}
@@ -15,29 +15,22 @@ export const uploadVideo: UploadMediaFn = async ({
const api = new MediaUploadApi();
progressCallback({ status: UploaderStates.uploadingImage });
try {
const response = await api.UploadNewVideo({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-video-${Date.now()}`,
maybe_title: `ref_video_${title}`,
});
const response = await api.UploadNewVideo({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-video-${Date.now()}`,
maybe_title: `ref_video_${title}`,
});
if (!response?.success || !response.data) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: response?.errorMessage ?? "Could not upload video",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
} catch (err) {
if (!response?.success || !response.data) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: err instanceof Error ? err.message : "Could not upload video",
errorMessage: response?.errorMessage ?? "Could not upload video",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
};
export const uploadAudio: UploadMediaFn = async ({
@@ -48,29 +41,22 @@ export const uploadAudio: UploadMediaFn = async ({
const api = new MediaUploadApi();
progressCallback({ status: UploaderStates.uploadingImage });
try {
const response = await api.UploadAudio({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-audio-${Date.now()}`,
maybe_title: `ref_audio_${title}`,
});
const response = await api.UploadAudio({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-audio-${Date.now()}`,
maybe_title: `ref_audio_${title}`,
});
if (!response?.success || !response.data) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: response?.errorMessage ?? "Could not upload audio",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
} catch (err) {
if (!response?.success || !response.data) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: err instanceof Error ? err.message : "Could not upload audio",
errorMessage: response?.errorMessage ?? "Could not upload audio",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
};
export const getVideoDuration = (file: File): Promise<number> =>
@@ -1,59 +0,0 @@
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCog } from "@fortawesome/pro-solid-svg-icons";
import { Switch } from "@storyteller/ui-switch";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-3xl" childPadding={false}>
<div className="h-[560px]">
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-ui-panel-border">
<div className="flex items-center gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
</div>
<hr className="my-2 w-full border-ui-panel-border" />
<div className="space-y-1">
<button className="h-9 w-full rounded-lg p-2 text-left bg-[#63636B]/20">
<div className="flex items-center gap-2.5 text-sm">
<FontAwesomeIcon icon={faCog} />
General
</div>
</button>
</div>
</div>
<div className="col-span-8 flex h-full flex-col overflow-y-auto relative">
<div className="w-full border-b border-ui-panel-border py-2.5 ps-0">
<h2 className="text-[18px] font-semibold">General</h2>
</div>
<div className="p-3 ps-0 text-sm h-full">
<div className="space-y-4 text-base-fg">
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Enter to generate</p>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter
adds a new line. When off (default), both Enter and
Shift+Enter add a new line — use the button to submit.
</p>
</div>
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} offClassName="bg-white/20" />
</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
}
@@ -35,13 +35,6 @@ export function showToast(type: ToastType, message: string, duration?: number) {
);
}
export const toast = {
success: (message: string, opts?: { duration?: number }) =>
showToast("success", message, opts?.duration),
error: (message: string, opts?: { duration?: number }) =>
showToast("error", message, opts?.duration),
};
// ── Component ──────────────────────────────────────────────────────────────
const DEFAULT_DURATION = 6000;
@@ -1,2 +0,0 @@
export { TruchetPattern, PagePatternBackdrop } from "./truchet-pattern";
export type { TruchetVariant } from "./truchet-pattern";
@@ -1,167 +0,0 @@
/**
* Subtle blue-tinted Truchet (random-diagonal) tile pattern used as a
* background flourish on landing, pricing, and content pages. Each variant
* has its own deterministic 6x4 grid so different pages feel related but
* not identical.
*/
type TruchetCell = "/" | "\\";
type TruchetGrid = readonly (readonly TruchetCell[])[];
export type TruchetVariant = "landing" | "pricing" | "content" | "auth";
const GRIDS: Record<TruchetVariant, TruchetGrid> = {
landing: [
["/", "\\", "/", "/", "\\", "/"],
["\\", "/", "/", "\\", "\\", "/"],
["/", "/", "\\", "/", "/", "\\"],
["\\", "/", "/", "\\", "/", "/"],
],
pricing: [
["\\", "\\", "/", "\\", "/", "\\"],
["/", "\\", "\\", "/", "\\", "/"],
["\\", "/", "\\", "\\", "/", "\\"],
["/", "\\", "/", "\\", "\\", "/"],
],
content: [
["/", "/", "\\", "/", "\\", "\\"],
["\\", "\\", "/", "\\", "/", "/"],
["/", "\\", "\\", "/", "\\", "/"],
["\\", "/", "/", "\\", "/", "\\"],
],
auth: [
["\\", "/", "\\", "/", "\\", "/"],
["/", "\\", "/", "\\", "/", "\\"],
["\\", "/", "/", "\\", "\\", "/"],
["/", "\\", "\\", "/", "/", "\\"],
],
};
interface TruchetPatternProps {
className?: string;
/** Multiplier on the default fill/stroke alpha. Defaults to 1. */
intensity?: number;
/** Which deterministic 6x4 grid to render. Defaults to "landing". */
variant?: TruchetVariant;
}
export function TruchetPattern({
className,
intensity = 1,
variant = "landing",
}: TruchetPatternProps) {
// Slightly reduced base opacity for a quieter overall feel.
const fillA = `rgba(120,170,255,${0.036 * intensity})`;
const fillB = `rgba(120,170,255,${0.0095 * intensity})`;
const diagStroke = `rgba(120,170,255,${0.16 * intensity})`;
const gridStroke = `rgba(120,170,255,${0.065 * intensity})`;
const grid = GRIDS[variant];
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1200 800"
preserveAspectRatio="xMidYMid slice"
className={className}
>
{grid.flatMap((row, ri) =>
row.map((dir, ci) => {
const x = ci * 200;
const y = ri * 200;
const isSlash = dir === "/";
const triA = isSlash
? `${x},${y} ${x + 200},${y} ${x},${y + 200}`
: `${x},${y} ${x + 200},${y} ${x + 200},${y + 200}`;
const triB = isSlash
? `${x + 200},${y} ${x + 200},${y + 200} ${x},${y + 200}`
: `${x},${y} ${x + 200},${y + 200} ${x},${y + 200}`;
const diag = isSlash
? `M${x},${y + 200}L${x + 200},${y}`
: `M${x},${y}L${x + 200},${y + 200}`;
return (
<g key={`${ri}-${ci}`}>
<polygon points={triA} fill={fillA} />
<polygon points={triB} fill={fillB} />
<path d={diag} stroke={diagStroke} strokeWidth="0.6" fill="none" />
</g>
);
}),
)}
{[1, 2, 3].map((i) => (
<line
key={`h${i}`}
x1="0"
y1={i * 200}
x2="1200"
y2={i * 200}
stroke={gridStroke}
strokeWidth="0.5"
/>
))}
{[1, 2, 3, 4, 5].map((i) => (
<line
key={`v${i}`}
x1={i * 200}
y1="0"
x2={i * 200}
y2="800"
stroke={gridStroke}
strokeWidth="0.5"
/>
))}
</svg>
);
}
export default TruchetPattern;
/**
* Drop-in subtle backdrop combining a top + bottom Truchet pattern with
* radial fades. Place once at the top of a page's root <div> (which must be
* `relative`, as most marketing pages already are).
*/
interface PagePatternBackdropProps {
variant?: TruchetVariant;
}
export function PagePatternBackdrop({
variant = "content",
}: PagePatternBackdropProps) {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[1100px] z-0"
style={{
maskImage:
"radial-gradient(ellipse 80% 60% at 50% 35%, black 25%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 80% 60% at 50% 35%, black 25%, transparent 80%)",
}}
>
<TruchetPattern
variant={variant}
intensity={0.5}
className="absolute inset-0 w-full h-full"
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-[700px] z-0"
style={{
maskImage:
"radial-gradient(ellipse 75% 60% at 50% 60%, black 20%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 75% 60% at 50% 60%, black 20%, transparent 80%)",
}}
>
<TruchetPattern
variant={variant}
intensity={0.5}
className="absolute inset-0 w-full h-full"
/>
</div>
</>
);
}

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