Initial Animatrix import
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
AI Gen Code/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
132
Animatrix Project Truth.md
Normal file
132
Animatrix Project Truth.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Animatrix Project Truth
|
||||
|
||||
Date: 2026-04-17
|
||||
|
||||
This document is the operational truth artifact for Animatrix.
|
||||
|
||||
This is the first pass. It is intentionally direct and incomplete-by-design rather than padded. Later passes should append deeper sections instead of replacing the existing truths.
|
||||
|
||||
## 1. What Animatrix Is
|
||||
|
||||
Animatrix is a production-oriented creative surface for grounded Wan 2.2 generation over the Desineuron stack.
|
||||
|
||||
It is not a general-purpose ComfyUI editor.
|
||||
|
||||
It is a constrained application that:
|
||||
|
||||
- stores user assets on the Linux box
|
||||
- authenticates operators locally
|
||||
- submits generation jobs through the backend
|
||||
- targets the stable Comfy ingress at `https://comfy.desineuron.in`
|
||||
- uses local Wan model files on the GPU box NVMe through ComfyUI
|
||||
|
||||
## 2. Current Runtime Topology
|
||||
|
||||
Current live topology:
|
||||
|
||||
- frontend: Linux box
|
||||
- backend: Linux box
|
||||
- auth and job DB: SQLite on Linux box
|
||||
- asset storage: Linux box local storage
|
||||
- output storage: Linux box local storage
|
||||
- workflow execution: ComfyUI on GPU box
|
||||
- public routing: `animatrix.desineuron.in` -> Linux box -> backend -> Comfy ingress -> GPU box
|
||||
|
||||
This matters because Animatrix must not hardcode raw GPU IP assumptions in app logic.
|
||||
|
||||
## 3. Current Live Workflow Truth
|
||||
|
||||
Current production workflow truth:
|
||||
|
||||
- primary live model path: `Wan 2.2 A14B`
|
||||
- current live animate workflow: `wan22_animate_move`
|
||||
- current implementation uses grounded start-frame image-to-video generation
|
||||
- current live aspect presets are injected into workflow inputs
|
||||
- current live duration presets are injected into workflow inputs
|
||||
|
||||
What is live now:
|
||||
|
||||
- start frame input
|
||||
- animate mode
|
||||
- audio performance mode surface
|
||||
- aspect ratio selection
|
||||
- duration selection
|
||||
- generation count selection from `x1` to `x4`
|
||||
- local Wan runtime model preflight against Comfy loader availability
|
||||
|
||||
What is not yet live in production workflow terms:
|
||||
|
||||
- true end-frame conditioning
|
||||
- exact start-to-end loop closure
|
||||
- guaranteed one-job-per-GPU scheduling semantics from the app layer
|
||||
- multiple production model families beyond Wan 2.2 A14B
|
||||
|
||||
These items must remain explicit in the UI and docs. Animatrix should not fake capability.
|
||||
|
||||
## 4. Why The Constraints Exist
|
||||
|
||||
The application must stay narrow because Wan/Comfy workflows are brittle under drift.
|
||||
|
||||
If the UI presents a control that has no faithful backend mapping, one of two things happens:
|
||||
|
||||
- the backend ignores it, which is deceptive
|
||||
- the workflow fails unpredictably, which destroys trust
|
||||
|
||||
The correct posture is:
|
||||
|
||||
- expose only what is truly wired
|
||||
- label planned features as planned
|
||||
- keep the prompt surface clean
|
||||
- preserve reliability over option count
|
||||
|
||||
## 5. UI Truth
|
||||
|
||||
The Animatrix UI should behave like a focused generation console.
|
||||
|
||||
That means:
|
||||
|
||||
- prompt-first
|
||||
- minimal surrounding chrome
|
||||
- only active controls near the prompt
|
||||
- settings grouped into one compact options surface
|
||||
- frame and ingredient concepts visible
|
||||
- unsupported frame-end features marked as not yet live
|
||||
|
||||
The target interaction model is closer to a modern studio composer than a dashboard CRUD form.
|
||||
|
||||
## 6. Batch Generation Truth
|
||||
|
||||
`x1` to `x4` in Animatrix means:
|
||||
|
||||
- the frontend/backend submit 1 to 4 independent jobs
|
||||
- each job is a real backend job row
|
||||
- each job is a real Comfy submission
|
||||
|
||||
What `x1` to `x4` does not guarantee on its own:
|
||||
|
||||
- hard binding to four distinct GPUs
|
||||
- guaranteed parallel execution without runtime scheduler support
|
||||
|
||||
Parallel execution depends on how the live Comfy/GPU runtime is configured.
|
||||
|
||||
## 7. Known Critical Operational Truths
|
||||
|
||||
The GPU runtime can appear healthy while still being unusable if model directories are empty.
|
||||
|
||||
Therefore Animatrix now needs to assume:
|
||||
|
||||
- workflow JSON validity is not enough
|
||||
- runtime loader availability must be checked against live Comfy object info
|
||||
|
||||
This is part of production safety, not optional validation.
|
||||
|
||||
## 8. Next Pass Targets
|
||||
|
||||
The next pass of this document should append:
|
||||
|
||||
- exact workflow inventory by mode
|
||||
- backend route truth
|
||||
- storage layout truth
|
||||
- deployment truth for Linux and GPU boxes
|
||||
- failure modes and recovery playbook
|
||||
- model hydration and verification truth
|
||||
@@ -0,0 +1,647 @@
|
||||
# Animatrix Build Prompt for Coding Agent - First Principles
|
||||
|
||||
Date: 2026-04-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is a direct implementation prompt for a coding agent. The agent should use this prompt to build Animatrix end to end as a production-oriented full-stack product.
|
||||
|
||||
This prompt is intentionally monolithic and explicit. The objective is to minimize ambiguity, reduce design drift, and make the output directly integrable into the repository with minimal reinterpretation.
|
||||
|
||||
## How To Use This Prompt
|
||||
|
||||
Give this entire document to the coding agent as its working brief. The agent should treat this document and the existing Animatrix SRS as the primary source of truth. The agent must build the application directly, not merely discuss architecture.
|
||||
|
||||
Companion source of truth:
|
||||
|
||||
- [Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md](F:\Workin In Progress\DESINEURON\GITLAB\Animatrix\Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md)
|
||||
|
||||
Infrastructure truths that must be respected:
|
||||
|
||||
- [comfyui_setup_truth.md](F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\.Agent Context\Sprint 1\comfyui_setup_truth.md)
|
||||
- [Desineuron Stable Ingress Handoff.md](F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\.Agent Context\Sprint 1\Desineuron Stable Ingress Handoff.md)
|
||||
|
||||
---
|
||||
|
||||
## Copy-Paste Prompt For The Coding Agent
|
||||
|
||||
You are implementing a new full-stack product called `Animatrix` inside the repository root at `F:\Workin In Progress\DESINEURON\GITLAB\Animatrix`.
|
||||
|
||||
You are not writing a concept note. You are building the actual implementation skeleton and as much of the product as possible in production shape.
|
||||
|
||||
Your mission is to build Animatrix end to end as a full-stack application with:
|
||||
|
||||
- frontend
|
||||
- backend API
|
||||
- simple database-backed authentication
|
||||
- upload pipeline
|
||||
- ComfyUI workflow orchestration
|
||||
- job persistence
|
||||
- result gallery
|
||||
- operator-safe integration with the live Desineuron stack
|
||||
|
||||
You must reason from first principles, but you must implement concrete code rather than remaining abstract.
|
||||
|
||||
## Product Definition
|
||||
|
||||
Animatrix is a focused product for guided character video generation using the Wan 2.2 family through ComfyUI.
|
||||
|
||||
It is not a general-purpose ComfyUI editor.
|
||||
|
||||
It exposes exactly two production workflows in v1:
|
||||
|
||||
1. `Animate Studio`
|
||||
- powered by `Wan2.2-Animate-14B`
|
||||
- supports `Move` and `Mix`
|
||||
- inputs: prompt, ground-truth image, motion video, optional references, optional pose sheet
|
||||
|
||||
2. `Audio Performance Studio`
|
||||
- powered by `Wan2.2-S2V-14B`
|
||||
- inputs: prompt, ground-truth image, audio, optional references, optional pose sheet
|
||||
|
||||
The product must provide a frontend similar in spirit to Google Flow:
|
||||
|
||||
- simple prompt composer
|
||||
- image chips for attachments
|
||||
- plus button to add optional assets
|
||||
- compact right-side mode switch
|
||||
- one-click generation
|
||||
|
||||
The product must not expose raw ComfyUI nodes to end users.
|
||||
|
||||
## Non-Negotiable Truths
|
||||
|
||||
You must respect the actual model capability split:
|
||||
|
||||
- `Wan2.2-Animate-14B` handles character animation and character replacement
|
||||
- `Wan2.2-S2V-14B` handles audio-driven video generation
|
||||
- exact first/last-frame control is not part of the first release
|
||||
- pose sheet support in v1 is soft guidance, not full deterministic control-video behavior
|
||||
|
||||
You must respect the existing Desineuron infrastructure:
|
||||
|
||||
- ComfyUI already exists and is exposed through `https://comfy.desineuron.in`
|
||||
- Do not connect to a raw GPU public IP
|
||||
- Do not hardcode the current GPU private IP
|
||||
- Treat `https://comfy.desineuron.in` as the execution dependency
|
||||
|
||||
You must respect the current ops posture:
|
||||
|
||||
- NVMe is used for model/runtime storage on GPU
|
||||
- ingress and TLS already exist
|
||||
- this product should sit cleanly on top of that system
|
||||
|
||||
## Implementation Goal
|
||||
|
||||
Build a repository-local full-stack application in `Animatrix/` with a structure that a team can continue directly.
|
||||
|
||||
The result must be implementation-grade, not pseudo-code.
|
||||
|
||||
You should build:
|
||||
|
||||
- frontend app
|
||||
- backend app
|
||||
- data models
|
||||
- database migrations or schema bootstrap
|
||||
- authentication layer
|
||||
- upload endpoints
|
||||
- ComfyUI client wrapper
|
||||
- workflow template binding layer
|
||||
- job runner
|
||||
- result persistence
|
||||
- UI pages
|
||||
- configuration examples
|
||||
- README or runbook
|
||||
|
||||
## Required Stack Choice
|
||||
|
||||
Default to the following unless the repository already strongly dictates otherwise:
|
||||
|
||||
- frontend: Next.js with TypeScript
|
||||
- backend: FastAPI with Python
|
||||
- database: SQLite for local/dev and clean migration path to PostgreSQL later
|
||||
- auth: simple email/password login with session cookie or JWT-based auth, but keep it simple
|
||||
- ORM: SQLAlchemy or SQLModel on backend
|
||||
- storage:
|
||||
- local filesystem for dev
|
||||
- abstraction for S3-compatible storage later
|
||||
|
||||
Reasoning:
|
||||
|
||||
- Next.js gives a fast frontend delivery path
|
||||
- FastAPI gives good async/media handling and aligns with the current Python-heavy ops environment
|
||||
- SQLite reduces bootstrap friction for immediate delivery
|
||||
- the design must preserve clean upgrade paths to PostgreSQL and S3
|
||||
|
||||
Do not overengineer with microservices in v1.
|
||||
|
||||
## Required Repository Structure
|
||||
|
||||
Create a clean structure under `Animatrix/` similar to:
|
||||
|
||||
```text
|
||||
Animatrix/
|
||||
frontend/
|
||||
backend/
|
||||
workflows/
|
||||
docs/
|
||||
scripts/
|
||||
infra/
|
||||
.env.example
|
||||
README.md
|
||||
```
|
||||
|
||||
Inside that:
|
||||
|
||||
```text
|
||||
frontend/
|
||||
src/
|
||||
app/
|
||||
components/
|
||||
features/
|
||||
lib/
|
||||
styles/
|
||||
|
||||
backend/
|
||||
app/
|
||||
api/
|
||||
core/
|
||||
db/
|
||||
models/
|
||||
schemas/
|
||||
services/
|
||||
repositories/
|
||||
workers/
|
||||
```
|
||||
|
||||
Create this structure if it does not exist.
|
||||
|
||||
## Product UX Requirements
|
||||
|
||||
The frontend must support:
|
||||
|
||||
- login page
|
||||
- registration page
|
||||
- authenticated dashboard
|
||||
- create-job composer
|
||||
- upload chips
|
||||
- mode switch between `Animate` and `Audio`
|
||||
- advanced settings drawer
|
||||
- job history list
|
||||
- individual result page
|
||||
|
||||
The composer must support:
|
||||
|
||||
- prompt text
|
||||
- ground-truth image upload
|
||||
- optional reference image uploads
|
||||
- optional pose sheet upload
|
||||
- motion video upload for Animate mode
|
||||
- audio upload for Audio mode
|
||||
- mode-specific validation
|
||||
|
||||
UI behavior requirements:
|
||||
|
||||
- keep the screen minimal
|
||||
- no enterprise CRUD feel
|
||||
- no purple default design
|
||||
- intentional, dark, focused creative-tool aesthetic is acceptable
|
||||
- avoid generic admin-dashboard look
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
Build a backend API that supports:
|
||||
|
||||
- auth
|
||||
- asset upload
|
||||
- asset metadata lookup
|
||||
- job creation
|
||||
- job status query
|
||||
- job output query
|
||||
- admin queue introspection
|
||||
|
||||
Required API groups:
|
||||
|
||||
- `/api/auth/*`
|
||||
- `/api/assets/*`
|
||||
- `/api/jobs/*`
|
||||
- `/api/admin/*`
|
||||
|
||||
Required core flows:
|
||||
|
||||
1. user logs in
|
||||
2. user uploads assets
|
||||
3. backend stores assets and metadata
|
||||
4. user submits a generation job
|
||||
5. backend validates mode-specific requirements
|
||||
6. backend selects workflow template
|
||||
7. backend uploads required assets to ComfyUI
|
||||
8. backend submits prompt JSON to ComfyUI
|
||||
9. backend tracks prompt ID and execution state
|
||||
10. backend collects final outputs and persists them
|
||||
11. frontend shows status and outputs
|
||||
|
||||
## Authentication Requirements
|
||||
|
||||
Implement a simple but real database-backed auth layer.
|
||||
|
||||
Minimum requirements:
|
||||
|
||||
- registration with email and password
|
||||
- password hashing
|
||||
- login
|
||||
- authenticated session
|
||||
- logout
|
||||
- user-job ownership relation
|
||||
|
||||
Keep the auth simple. Do not bring in OAuth or external identity providers in v1 unless there is a compelling reason.
|
||||
|
||||
If you use JWT, store it safely. If you use cookie sessions, configure them properly.
|
||||
|
||||
Preferred outcome:
|
||||
|
||||
- email/password auth
|
||||
- hashed passwords with `passlib` or equivalent
|
||||
- server-side session or signed JWT cookie
|
||||
|
||||
## Database Requirements
|
||||
|
||||
Implement a database with at least these tables or equivalent models:
|
||||
|
||||
- `users`
|
||||
- `assets`
|
||||
- `jobs`
|
||||
- `job_outputs`
|
||||
- `job_events`
|
||||
|
||||
Suggested minimum fields:
|
||||
|
||||
### users
|
||||
|
||||
- id
|
||||
- email
|
||||
- password_hash
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### assets
|
||||
|
||||
- id
|
||||
- owner_id
|
||||
- asset_type
|
||||
- mime_type
|
||||
- original_filename
|
||||
- storage_path
|
||||
- thumbnail_path
|
||||
- size_bytes
|
||||
- width
|
||||
- height
|
||||
- duration_seconds
|
||||
- created_at
|
||||
|
||||
### jobs
|
||||
|
||||
- id
|
||||
- owner_id
|
||||
- mode
|
||||
- submode
|
||||
- prompt
|
||||
- negative_prompt
|
||||
- status
|
||||
- comfy_prompt_id
|
||||
- workflow_template_name
|
||||
- workflow_template_version
|
||||
- settings_json
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### job_outputs
|
||||
|
||||
- id
|
||||
- job_id
|
||||
- output_type
|
||||
- file_path
|
||||
- poster_path
|
||||
- metadata_json
|
||||
- created_at
|
||||
|
||||
### job_events
|
||||
|
||||
- id
|
||||
- job_id
|
||||
- event_type
|
||||
- message
|
||||
- payload_json
|
||||
- created_at
|
||||
|
||||
Use migrations or a bootstrapping strategy that is clean and rerunnable.
|
||||
|
||||
## Storage Requirements
|
||||
|
||||
Implement a storage abstraction rather than hardcoding one persistence model everywhere.
|
||||
|
||||
For v1:
|
||||
|
||||
- local filesystem storage is acceptable
|
||||
|
||||
But build the code so it can later support:
|
||||
|
||||
- S3 object storage for uploads
|
||||
- S3 object storage for outputs
|
||||
|
||||
You must create service boundaries for:
|
||||
|
||||
- asset storage
|
||||
- output storage
|
||||
- thumbnail generation
|
||||
|
||||
## ComfyUI Integration Requirements
|
||||
|
||||
Implement a clean ComfyUI client service.
|
||||
|
||||
It must be the only layer that talks directly to:
|
||||
|
||||
- `POST /prompt`
|
||||
- `GET /history/{prompt_id}`
|
||||
- `GET /queue`
|
||||
- `POST /upload/image`
|
||||
|
||||
If needed, also support video or audio uploads using the proper Comfy-compatible upload handling strategy in your backend integration path.
|
||||
|
||||
Design a service like:
|
||||
|
||||
- `ComfyClient`
|
||||
- `WorkflowBinder`
|
||||
- `JobOrchestrator`
|
||||
|
||||
The backend must never scatter raw requests across multiple files.
|
||||
|
||||
## Workflow Requirements
|
||||
|
||||
Create versioned workflow template placeholders under:
|
||||
|
||||
- `workflows/animate/`
|
||||
- `workflows/s2v/`
|
||||
- `workflows/shared/`
|
||||
|
||||
Even if the exact final production JSON is not fully available yet, create the binding architecture properly now.
|
||||
|
||||
You must implement:
|
||||
|
||||
- template loading
|
||||
- parameter injection
|
||||
- node ID mapping
|
||||
- workflow version metadata
|
||||
|
||||
The system must support:
|
||||
|
||||
- `Wan2.2-Animate-14B` for `move` and `mix`
|
||||
- `Wan2.2-S2V-14B` for audio-driven generation
|
||||
|
||||
Do not fake support for first/last-frame workflow in v1.
|
||||
|
||||
## Job Execution Model
|
||||
|
||||
Implement an execution model that is supportable.
|
||||
|
||||
Minimum acceptable design:
|
||||
|
||||
- create job record in DB
|
||||
- perform async orchestration
|
||||
- update job status as it moves through states
|
||||
|
||||
Use explicit statuses:
|
||||
|
||||
- `created`
|
||||
- `validating`
|
||||
- `uploading_assets`
|
||||
- `queued`
|
||||
- `executing`
|
||||
- `collecting_outputs`
|
||||
- `completed`
|
||||
- `failed`
|
||||
|
||||
If background jobs are needed, choose a simple and supportable path. Acceptable examples:
|
||||
|
||||
- FastAPI background tasks for MVP
|
||||
- a simple worker loop
|
||||
- lightweight task queue if necessary
|
||||
|
||||
Do not pull in a large distributed system unless it materially improves delivery.
|
||||
|
||||
## Frontend Requirements in Detail
|
||||
|
||||
Build a polished frontend that includes:
|
||||
|
||||
### Pages
|
||||
|
||||
- `/login`
|
||||
- `/register`
|
||||
- `/dashboard`
|
||||
- `/jobs/[id]`
|
||||
|
||||
### Main dashboard behavior
|
||||
|
||||
The dashboard should have:
|
||||
|
||||
- a main creation composer at the top
|
||||
- recent outputs or job history below
|
||||
- simple filter between `All`, `Animate`, and `Audio`
|
||||
|
||||
### Composer behavior
|
||||
|
||||
The composer should allow:
|
||||
|
||||
- prompt entry
|
||||
- attachment chip previews
|
||||
- plus button to add more attachments
|
||||
- mode switch
|
||||
- advanced settings panel
|
||||
- submit button
|
||||
|
||||
### Visual design
|
||||
|
||||
Design it as a creative tool:
|
||||
|
||||
- dark, cinematic, focused
|
||||
- strong spacing and clean hierarchy
|
||||
- rounded attachment chips
|
||||
- non-generic toggle controls
|
||||
- clear submit affordance
|
||||
|
||||
Do not build a dull form-driven admin UI.
|
||||
|
||||
## API and UI Contract
|
||||
|
||||
The frontend must not send raw files straight into a final job call.
|
||||
|
||||
Preferred flow:
|
||||
|
||||
1. upload assets first
|
||||
2. receive asset IDs
|
||||
3. create job with asset IDs and settings
|
||||
|
||||
The job creation payload should clearly map to workflow mode.
|
||||
|
||||
## Validation Requirements
|
||||
|
||||
You must enforce:
|
||||
|
||||
### Shared
|
||||
|
||||
- prompt presence if required
|
||||
- ground-truth image required
|
||||
|
||||
### Animate mode
|
||||
|
||||
- motion video required
|
||||
- `move` or `mix` required
|
||||
|
||||
### Audio mode
|
||||
|
||||
- audio required
|
||||
|
||||
### Optional inputs
|
||||
|
||||
- reference images optional
|
||||
- pose sheet optional
|
||||
|
||||
Treat pose sheet as reference guidance in metadata, not as deterministic control.
|
||||
|
||||
## Result Delivery Requirements
|
||||
|
||||
Each completed job should produce:
|
||||
|
||||
- main video result
|
||||
- poster or thumbnail
|
||||
- metadata record
|
||||
|
||||
The frontend job page should show:
|
||||
|
||||
- current status
|
||||
- prompt
|
||||
- attached assets summary
|
||||
- output preview
|
||||
- download link
|
||||
|
||||
## Required Documentation Deliverables
|
||||
|
||||
While implementing, also create:
|
||||
|
||||
- top-level `README.md`
|
||||
- backend `.env.example`
|
||||
- frontend `.env.example` if needed
|
||||
- a short `RUNBOOK.md` or equivalent setup section in README
|
||||
|
||||
The documentation must explain:
|
||||
|
||||
- how to run backend
|
||||
- how to run frontend
|
||||
- what env vars are needed
|
||||
- how ComfyUI host is configured
|
||||
|
||||
## Environment Configuration Requirements
|
||||
|
||||
Design configuration for:
|
||||
|
||||
- backend base URL
|
||||
- frontend base URL
|
||||
- database URL
|
||||
- asset storage root
|
||||
- output storage root
|
||||
- ComfyUI base URL
|
||||
- session secret or JWT secret
|
||||
|
||||
Use env vars.
|
||||
|
||||
Do not hardcode production hostnames directly in the implementation logic.
|
||||
|
||||
## Integration Constraints
|
||||
|
||||
You must build this so it can later integrate cleanly into the wider Desineuron stack.
|
||||
|
||||
That means:
|
||||
|
||||
- no breaking assumptions about direct GPU access
|
||||
- no hidden dependency on localhost-only ComfyUI
|
||||
- no assumption that the frontend and backend must share one process
|
||||
- no brittle absolute-path assumptions in core business logic
|
||||
|
||||
## Coding Style Constraints
|
||||
|
||||
- keep modules small and purposeful
|
||||
- avoid giant god-classes
|
||||
- use typed request and response schemas
|
||||
- use explicit service boundaries
|
||||
- use reusable validation helpers
|
||||
- keep file names and routes clear
|
||||
|
||||
## What Not To Do
|
||||
|
||||
Do not:
|
||||
|
||||
- build a fake prototype with no real backend
|
||||
- expose raw ComfyUI workflows to users
|
||||
- pretend one Wan model does everything
|
||||
- hardcode temporary machine IPs
|
||||
- design a node-editor frontend
|
||||
- skip auth because it is "only MVP"
|
||||
- overcomplicate with Kubernetes, event buses, or multi-service orchestration in v1
|
||||
|
||||
## Expected Output From This Task
|
||||
|
||||
At the end of implementation, you should have produced:
|
||||
|
||||
- a working full-stack Animatrix codebase under `Animatrix/`
|
||||
- a login flow
|
||||
- a functional dashboard UI
|
||||
- upload and job creation API
|
||||
- database models and initialization
|
||||
- ComfyUI integration layer
|
||||
- workflow-binding layer
|
||||
- job status and result views
|
||||
- setup documentation
|
||||
|
||||
If a portion cannot be fully completed because exact production workflow JSON files are still pending, then:
|
||||
|
||||
- implement the surrounding infrastructure completely
|
||||
- create strict template placeholders
|
||||
- document exactly what needs to be dropped in later
|
||||
- do not leave the architecture vague
|
||||
|
||||
## Execution Plan You Should Follow
|
||||
|
||||
1. Create repository structure.
|
||||
2. Scaffold backend with config, DB, auth, and API layout.
|
||||
3. Scaffold frontend with login, dashboard, and job pages.
|
||||
4. Implement asset upload and storage services.
|
||||
5. Implement ComfyUI client wrapper.
|
||||
6. Implement workflow binder and mode selection logic.
|
||||
7. Implement async job orchestration and status persistence.
|
||||
8. Implement output persistence and result views.
|
||||
9. Add documentation and environment examples.
|
||||
10. Verify the project boots locally in a clean development flow.
|
||||
|
||||
## Final Rule
|
||||
|
||||
You are expected to produce real code, not a design memo.
|
||||
|
||||
Where a hard production dependency is missing, build the complete surrounding system and leave a narrow, explicit seam rather than handwaving.
|
||||
|
||||
---
|
||||
|
||||
## Operator Note
|
||||
|
||||
This prompt is intentionally designed to make the coding agent behave like an implementation engineer rather than an ideation partner.
|
||||
|
||||
The correct next use of this file is:
|
||||
|
||||
1. bring this file into the coding agent context
|
||||
2. also provide the Animatrix SRS
|
||||
3. instruct the agent to implement directly in `Animatrix/`
|
||||
|
||||
## Recommended Pairing Prompt
|
||||
|
||||
If you want a shorter launcher prompt to accompany this file, use:
|
||||
|
||||
`Use the attached Animatrix build prompt and Animatrix SRS as the source of truth. Implement the full-stack product directly inside the Animatrix folder. Build the frontend, backend, DB-backed auth, asset upload pipeline, workflow binding layer, ComfyUI integration, job tracking, and result pages. Do not stop at planning. Write the code and docs.`
|
||||
1237
Docs/Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md
Normal file
1237
Docs/Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md
Normal file
File diff suppressed because it is too large
Load Diff
64
Docs/RUNBOOK.md
Normal file
64
Docs/RUNBOOK.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Animatrix Runbook
|
||||
|
||||
## Production Runtime
|
||||
|
||||
- frontend: `127.0.0.1:3200`
|
||||
- backend: `127.0.0.1:8200`
|
||||
- public hostname: `animatrix.desineuron.in`
|
||||
- Comfy dependency: `https://comfy.desineuron.in`
|
||||
- frontend service root: `/opt/animatrix/frontend/.next/standalone`
|
||||
- backend env should include `BACKEND_BASE_URL=https://animatrix.desineuron.in`
|
||||
|
||||
## Frontend Build Packaging
|
||||
|
||||
The frontend is deployed using Next.js standalone output. After every build, static assets must be copied into the standalone directory or the site will render unstyled because `/_next/static/css/*` will 404.
|
||||
|
||||
Required packaging flow:
|
||||
|
||||
```bash
|
||||
cd /opt/animatrix
|
||||
./scripts/build_frontend_standalone.sh
|
||||
sudo systemctl restart animatrix-frontend
|
||||
sudo systemctl restart animatrix-backend
|
||||
```
|
||||
|
||||
If the site looks like plain HTML with unstyled buttons and form controls, check:
|
||||
|
||||
```bash
|
||||
curl -I https://animatrix.desineuron.in/_next/static/css/<hash>.css
|
||||
```
|
||||
|
||||
That request must return `200`, not `404`.
|
||||
|
||||
## Auth Cookie Safety
|
||||
|
||||
The backend now treats forwarded HTTPS headers from nginx as authoritative for cookie security. Even so, keep:
|
||||
|
||||
```bash
|
||||
BACKEND_BASE_URL=https://animatrix.desineuron.in
|
||||
```
|
||||
|
||||
in the production backend environment so generated backend URLs and cookie policy stay aligned with the public host.
|
||||
|
||||
## First Production Check
|
||||
|
||||
1. Open `https://animatrix.desineuron.in/login`
|
||||
2. Register a user
|
||||
3. Upload one image
|
||||
4. Upload one video or one audio file depending on mode
|
||||
5. Submit a job
|
||||
6. Confirm job transitions out of `created`
|
||||
|
||||
## Expected Non-Test Scope
|
||||
|
||||
This deployment verifies:
|
||||
|
||||
- app hosting
|
||||
- TLS
|
||||
- backend reachability
|
||||
- auth flow
|
||||
- asset upload
|
||||
- job creation
|
||||
- ComfyUI connectivity path
|
||||
|
||||
It does not assert final video quality or creative correctness.
|
||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Animatrix
|
||||
|
||||
Animatrix is a constrained full-stack Wan 2.2 generation surface built on top of the live Desineuron ComfyUI ingress.
|
||||
|
||||
It is designed to be a production-facing prompt console, not a node editor.
|
||||
|
||||
## Current Product Scope
|
||||
|
||||
Current live scope:
|
||||
|
||||
- local operator auth
|
||||
- asset upload and library
|
||||
- grounded start-frame generation
|
||||
- animate mode
|
||||
- audio performance surface
|
||||
- aspect ratio selection
|
||||
- duration selection
|
||||
- batch submission from `x1` to `x4`
|
||||
- result viewing and download
|
||||
|
||||
Current non-live scope:
|
||||
|
||||
- true end-frame conditioning
|
||||
- exact loop closure workflows
|
||||
- additional model families beyond Wan 2.2 A14B
|
||||
|
||||
If a control is not wired to the live Comfy workflow, it should not be treated as production-capable.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `frontend/`: Next.js operator surface
|
||||
- `backend/`: FastAPI API, auth, storage, orchestration
|
||||
- `workflows/`: versioned ComfyUI workflow templates
|
||||
- `infra/`: deployment and service scaffolding
|
||||
- `scripts/`: utility scripts
|
||||
- `Animatrix Project Truth.md`: project truth artifact
|
||||
|
||||
## Runtime Topology
|
||||
|
||||
Production topology today:
|
||||
|
||||
- frontend: Linux box
|
||||
- backend: Linux box
|
||||
- public host: `animatrix.desineuron.in`
|
||||
- Comfy target: `https://comfy.desineuron.in`
|
||||
- GPU execution: AWS GPU box using local Wan models on NVMe
|
||||
|
||||
The app should never assume direct browser access to the GPU box.
|
||||
|
||||
## Local Development
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
python run.py
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp .env.local.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Local production-style frontend build:
|
||||
|
||||
```bash
|
||||
./scripts/build_frontend_standalone.sh
|
||||
```
|
||||
|
||||
## Production Configuration Notes
|
||||
|
||||
Expected production assumptions:
|
||||
|
||||
- frontend browser calls use same-origin `/api`
|
||||
- backend `COMFYUI_BASE_URL` points to `https://comfy.desineuron.in`
|
||||
- backend `BACKEND_BASE_URL` points to `https://animatrix.desineuron.in`
|
||||
- Linux box stores app DB, uploaded assets, and persisted outputs
|
||||
- GPU box stores ComfyUI runtime and Wan model files on NVMe
|
||||
|
||||
## Operational Notes
|
||||
|
||||
Two truths matter in production:
|
||||
|
||||
1. A valid workflow file does not guarantee a valid runtime.
|
||||
2. Comfy loader availability must match the model names referenced in the workflow.
|
||||
|
||||
Animatrix now depends on live runtime model preflight to avoid silent drift.
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with:
|
||||
|
||||
- [Animatrix Project Truth.md](F:/Workin%20In%20Progress/DESINEURON/GITLAB/Animatrix/Animatrix%20Project%20Truth.md)
|
||||
|
||||
This README is the first stronger pass and should continue to evolve with deployment truth, workflow inventory, and operator runbooks.
|
||||
11
backend/.env.example
Normal file
11
backend/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
SECRET_KEY=change-me
|
||||
DATABASE_URL=sqlite:///./animatrix.db
|
||||
ASSET_STORAGE_ROOT=./storage/assets
|
||||
OUTPUT_STORAGE_ROOT=./storage/outputs
|
||||
COMFYUI_BASE_URL=https://comfy.desineuron.in
|
||||
# Set to the public HTTPS origin in production so generated backend URLs and cookie policy stay correct.
|
||||
# Example production value:
|
||||
# BACKEND_BASE_URL=https://animatrix.desineuron.in
|
||||
BACKEND_BASE_URL=http://localhost:8000
|
||||
CORS_ORIGINS=http://localhost:3000,https://animatrix.desineuron.in
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
backend/app/api/routes/admin.py
Normal file
28
backend/app/api/routes/admin.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models import Job, User
|
||||
from app.services.comfy_client import comfy_client
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health(_: User = Depends(get_current_user)):
|
||||
return {"api": "ok", "comfyui": await comfy_client.health_check()}
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
async def queue(_: User = Depends(get_current_user)):
|
||||
return await comfy_client.get_queue()
|
||||
|
||||
|
||||
@router.get("/jobs-summary")
|
||||
def jobs_summary(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||
total = db.query(Job).count()
|
||||
active = db.query(Job).filter(Job.status.in_(["validating", "uploading_assets", "queued", "executing", "collecting_outputs"])).count()
|
||||
completed = db.query(Job).filter(Job.status == "completed").count()
|
||||
failed = db.query(Job).filter(Job.status == "failed").count()
|
||||
return {"total": total, "active": active, "completed": completed, "failed": failed}
|
||||
120
backend/app/api/routes/assets.py
Normal file
120
backend/app/api/routes/assets.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models import Asset, User
|
||||
from app.schemas import AssetResponse, AssetTrashRequest
|
||||
from app.services.storage import asset_storage
|
||||
|
||||
router = APIRouter(prefix="/api/assets", tags=["assets"])
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
"image": ["image/jpeg", "image/png", "image/webp"],
|
||||
"video": ["video/mp4", "video/webm", "video/quicktime"],
|
||||
"audio": ["audio/mpeg", "audio/mp4", "audio/wav", "audio/ogg", "audio/x-wav"],
|
||||
"pose_sheet": ["image/jpeg", "image/png", "image/webp"],
|
||||
}
|
||||
MAX_SIZE_BYTES = 500 * 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/upload", response_model=AssetResponse, status_code=201)
|
||||
async def upload_asset(
|
||||
file: UploadFile = File(...),
|
||||
asset_type: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if asset_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, f"asset_type must be one of {list(ALLOWED_TYPES.keys())}")
|
||||
|
||||
mime = file.content_type or ""
|
||||
if mime not in ALLOWED_TYPES[asset_type]:
|
||||
raise HTTPException(400, f"Unsupported mime type {mime} for {asset_type}")
|
||||
|
||||
subfolder = f"{current_user.id}/{asset_type}"
|
||||
storage_path, size_bytes = await asset_storage.save_upload(file, subfolder)
|
||||
if size_bytes > MAX_SIZE_BYTES:
|
||||
raise HTTPException(413, "File too large (max 500MB)")
|
||||
|
||||
thumbnail_path = None
|
||||
width = height = None
|
||||
duration_seconds = None
|
||||
if asset_type in ("image", "pose_sheet"):
|
||||
thumbnail_path = asset_storage.generate_thumbnail(storage_path, f"{current_user.id}/thumbs")
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
abs_path = asset_storage.absolute_path(storage_path)
|
||||
with Image.open(abs_path) as image:
|
||||
width, height = image.size
|
||||
except Exception:
|
||||
pass
|
||||
elif asset_type == "video":
|
||||
thumbnail_path = asset_storage.generate_video_thumbnail(storage_path, f"{current_user.id}/thumbs")
|
||||
duration_seconds = asset_storage.detect_duration_seconds(storage_path)
|
||||
else:
|
||||
duration_seconds = asset_storage.detect_duration_seconds(storage_path)
|
||||
|
||||
asset = Asset(
|
||||
owner_id=current_user.id,
|
||||
asset_type=asset_type,
|
||||
mime_type=mime,
|
||||
original_filename=file.filename or "upload",
|
||||
storage_path=storage_path,
|
||||
thumbnail_path=thumbnail_path,
|
||||
size_bytes=size_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
db.add(asset)
|
||||
db.commit()
|
||||
db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.get("/", response_model=List[AssetResponse])
|
||||
def list_assets(
|
||||
asset_type: Optional[str] = None,
|
||||
include_trashed: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = db.query(Asset).filter(Asset.owner_id == current_user.id)
|
||||
if not include_trashed:
|
||||
query = query.filter(Asset.is_trashed.is_(False))
|
||||
if asset_type:
|
||||
query = query.filter(Asset.asset_type == asset_type)
|
||||
return query.order_by(Asset.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/trash", status_code=200)
|
||||
def move_assets_to_trash(
|
||||
payload: AssetTrashRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not payload.asset_ids:
|
||||
raise HTTPException(400, "No asset ids provided")
|
||||
|
||||
assets = (
|
||||
db.query(Asset)
|
||||
.filter(Asset.owner_id == current_user.id, Asset.id.in_(payload.asset_ids))
|
||||
.all()
|
||||
)
|
||||
if not assets:
|
||||
raise HTTPException(404, "No matching assets found")
|
||||
|
||||
delete_after_at = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
for asset in assets:
|
||||
asset.is_trashed = True
|
||||
asset.delete_after_at = delete_after_at
|
||||
db.commit()
|
||||
return {
|
||||
"moved_to_trash": len(assets),
|
||||
"delete_after_at": delete_after_at.isoformat(),
|
||||
}
|
||||
61
backend/app/api/routes/auth.py
Normal file
61
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.models import User
|
||||
from app.schemas import LoginRequest, RegisterRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
||||
existing = db.query(User).filter(User.email == payload.email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user = User(email=payload.email, password_hash=hash_password(payload.password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def _is_secure_request(request: Request) -> bool:
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "")
|
||||
if "https" in forwarded_proto.lower():
|
||||
return True
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
return settings.BACKEND_BASE_URL.startswith("https://")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == payload.email).first()
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
token = create_access_token(subject=user.id)
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=_is_secure_request(request),
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
)
|
||||
return {"message": "Logged in", "user": UserResponse.model_validate(user)}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(response: Response):
|
||||
response.delete_cookie("access_token")
|
||||
return {"message": "Logged out"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
118
backend/app/api/routes/jobs.py
Normal file
118
backend/app/api/routes/jobs.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models import Asset, Job, JobOutput, User
|
||||
from app.schemas import JobCreateRequest, JobListResponse, JobResponse
|
||||
from app.services.orchestrator import reconcile_job_outputs_if_missing, run_job
|
||||
from app.services.storage import output_storage
|
||||
|
||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
|
||||
|
||||
@router.post("/", response_model=JobResponse, status_code=201)
|
||||
async def create_job(
|
||||
payload: JobCreateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
def assert_owns(asset_id: Optional[str], label: str):
|
||||
if asset_id:
|
||||
asset = (
|
||||
db.query(Asset)
|
||||
.filter(Asset.id == asset_id, Asset.owner_id == current_user.id, Asset.is_trashed.is_(False))
|
||||
.first()
|
||||
)
|
||||
if not asset:
|
||||
raise HTTPException(400, f"{label} asset not found or not owned by user")
|
||||
|
||||
assert_owns(payload.ground_truth_asset_id, "ground_truth")
|
||||
assert_owns(payload.motion_asset_id, "motion")
|
||||
assert_owns(payload.audio_asset_id, "audio")
|
||||
assert_owns(payload.pose_asset_id, "pose_sheet")
|
||||
for ref_id in payload.reference_asset_ids or []:
|
||||
assert_owns(ref_id, f"reference {ref_id}")
|
||||
|
||||
job = Job(
|
||||
owner_id=current_user.id,
|
||||
mode=payload.mode,
|
||||
submode=payload.submode,
|
||||
prompt=payload.prompt,
|
||||
negative_prompt=payload.negative_prompt,
|
||||
status="created",
|
||||
ground_truth_asset_id=payload.ground_truth_asset_id,
|
||||
motion_asset_id=payload.motion_asset_id,
|
||||
audio_asset_id=payload.audio_asset_id,
|
||||
pose_asset_id=payload.pose_asset_id,
|
||||
reference_asset_ids_json=json.dumps(payload.reference_asset_ids) if payload.reference_asset_ids else None,
|
||||
settings_json=json.dumps(payload.settings) if payload.settings else None,
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
|
||||
background_tasks.add_task(run_job, job.id)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/", response_model=List[JobListResponse])
|
||||
def list_jobs(
|
||||
mode: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = db.query(Job).filter(Job.owner_id == current_user.id)
|
||||
query = query.options(selectinload(Job.outputs))
|
||||
if mode:
|
||||
query = query.filter(Job.mode == mode)
|
||||
if status:
|
||||
query = query.filter(Job.status == status)
|
||||
return query.order_by(Job.created_at.desc()).limit(100).all()
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=JobResponse)
|
||||
async def get_job(job_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
job = (
|
||||
db.query(Job)
|
||||
.options(selectinload(Job.outputs), selectinload(Job.events))
|
||||
.filter(Job.id == job_id, Job.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job.status == "completed" and not job.outputs and job.comfy_prompt_id:
|
||||
await reconcile_job_outputs_if_missing(job.id)
|
||||
db.expire_all()
|
||||
job = (
|
||||
db.query(Job)
|
||||
.options(selectinload(Job.outputs), selectinload(Job.events))
|
||||
.filter(Job.id == job_id, Job.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/{job_id}/outputs/{output_id}/download")
|
||||
def download_output(
|
||||
job_id: str,
|
||||
output_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
job = db.query(Job).filter(Job.id == job_id, Job.owner_id == current_user.id).first()
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
output = db.query(JobOutput).filter(JobOutput.id == output_id, JobOutput.job_id == job_id).first()
|
||||
if not output:
|
||||
raise HTTPException(404, "Output not found")
|
||||
abs_path = output_storage.absolute_path(output.file_path)
|
||||
if not abs_path.exists():
|
||||
raise HTTPException(404, "Output file not found")
|
||||
return FileResponse(str(abs_path))
|
||||
25
backend/app/core/config.py
Normal file
25
backend/app/core/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SECRET_KEY: str = "dev-secret-change-in-production"
|
||||
DATABASE_URL: str = "sqlite:///./animatrix.db"
|
||||
ASSET_STORAGE_ROOT: str = "./storage/assets"
|
||||
OUTPUT_STORAGE_ROOT: str = "./storage/outputs"
|
||||
COMFYUI_BASE_URL: str = "https://comfy.desineuron.in"
|
||||
BACKEND_BASE_URL: str = "http://localhost:8000"
|
||||
CORS_ORIGINS: str = "http://localhost:3000"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
30
backend/app/core/deps.py
Normal file
30
backend/app/core/deps.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.session import get_db
|
||||
from app.models import User
|
||||
|
||||
|
||||
def get_current_user(
|
||||
access_token: Optional[str] = Cookie(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
)
|
||||
if not access_token:
|
||||
raise credentials_exception
|
||||
|
||||
user_id = decode_access_token(access_token)
|
||||
if not user_id:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first()
|
||||
if not user:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
34
backend/app/core/security.py
Normal file
34
backend/app/core/security.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(subject: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[str]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
63
backend/app/db/init_db.py
Normal file
63
backend/app/db/init_db.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import Base, engine
|
||||
from app.models import Asset, Job, JobEvent, JobOutput, User # noqa: F401
|
||||
from app.services.storage import asset_storage
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Path(settings.ASSET_STORAGE_ROOT).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.OUTPUT_STORAGE_ROOT).mkdir(parents=True, exist_ok=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_assets_table()
|
||||
_cleanup_expired_trashed_assets()
|
||||
|
||||
|
||||
def _migrate_assets_table() -> None:
|
||||
with engine.begin() as conn:
|
||||
columns = {
|
||||
row[1]
|
||||
for row in conn.execute(text("PRAGMA table_info(assets)")).fetchall()
|
||||
}
|
||||
if "is_trashed" not in columns:
|
||||
conn.execute(text("ALTER TABLE assets ADD COLUMN is_trashed BOOLEAN NOT NULL DEFAULT 0"))
|
||||
if "delete_after_at" not in columns:
|
||||
conn.execute(text("ALTER TABLE assets ADD COLUMN delete_after_at DATETIME NULL"))
|
||||
|
||||
|
||||
def _cleanup_expired_trashed_assets() -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, storage_path, thumbnail_path
|
||||
FROM assets
|
||||
WHERE is_trashed = 1
|
||||
AND delete_after_at IS NOT NULL
|
||||
AND delete_after_at <= :now
|
||||
"""
|
||||
),
|
||||
{"now": now},
|
||||
).fetchall()
|
||||
|
||||
for _, storage_path, thumbnail_path in rows:
|
||||
asset_storage.delete_relative_path(storage_path)
|
||||
asset_storage.delete_relative_path(thumbnail_path)
|
||||
|
||||
if rows:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM assets
|
||||
WHERE is_trashed = 1
|
||||
AND delete_after_at IS NOT NULL
|
||||
AND delete_after_at <= :now
|
||||
"""
|
||||
),
|
||||
{"now": now},
|
||||
)
|
||||
23
backend/app/db/session.py
Normal file
23
backend/app/db/session.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
connect_args = {}
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
32
backend/app/main.py
Normal file
32
backend/app/main.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.routes import admin, assets, auth, jobs
|
||||
from app.core.config import settings
|
||||
from app.db.init_db import init_db
|
||||
|
||||
app = FastAPI(title="Animatrix API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
init_db()
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(assets.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
app.mount("/storage/assets", StaticFiles(directory=settings.ASSET_STORAGE_ROOT), name="assets")
|
||||
app.mount("/storage/outputs", StaticFiles(directory=settings.OUTPUT_STORAGE_ROOT), name="outputs")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
3
backend/app/models/__init__.py
Normal file
3
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.models.models import Asset, Job, JobEvent, JobOutput, User
|
||||
|
||||
__all__ = ["User", "Asset", "Job", "JobOutput", "JobEvent"]
|
||||
106
backend/app/models/models.py
Normal file
106
backend/app/models/models.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid)
|
||||
email: Mapped[str] = mapped_column(String, unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
assets: Mapped[list["Asset"]] = relationship("Asset", back_populates="owner", cascade="all, delete-orphan")
|
||||
jobs: Mapped[list["Job"]] = relationship("Job", back_populates="owner", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Asset(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid)
|
||||
owner_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), nullable=False, index=True)
|
||||
asset_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
original_filename: Mapped[str] = mapped_column(String, nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(String, nullable=False)
|
||||
thumbnail_path: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
duration_seconds: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
is_trashed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
delete_after_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="assets")
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid)
|
||||
owner_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(String, nullable=False)
|
||||
submode: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
negative_prompt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="created", index=True)
|
||||
comfy_prompt_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
workflow_template_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
workflow_template_version: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
settings_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
ground_truth_asset_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("assets.id"), nullable=True)
|
||||
motion_asset_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("assets.id"), nullable=True)
|
||||
audio_asset_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("assets.id"), nullable=True)
|
||||
reference_asset_ids_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
pose_asset_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("assets.id"), nullable=True)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="jobs")
|
||||
outputs: Mapped[list["JobOutput"]] = relationship("JobOutput", back_populates="job", cascade="all, delete-orphan")
|
||||
events: Mapped[list["JobEvent"]] = relationship("JobEvent", back_populates="job", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class JobOutput(Base):
|
||||
__tablename__ = "job_outputs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid)
|
||||
job_id: Mapped[str] = mapped_column(String, ForeignKey("jobs.id"), nullable=False, index=True)
|
||||
output_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
file_path: Mapped[str] = mapped_column(String, nullable=False)
|
||||
poster_path: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
job: Mapped["Job"] = relationship("Job", back_populates="outputs")
|
||||
|
||||
|
||||
class JobEvent(Base):
|
||||
__tablename__ = "job_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid)
|
||||
job_id: Mapped[str] = mapped_column(String, ForeignKey("jobs.id"), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
payload_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
job: Mapped["Job"] = relationship("Job", back_populates="events")
|
||||
25
backend/app/schemas/__init__.py
Normal file
25
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from app.schemas.schemas import (
|
||||
AssetResponse,
|
||||
AssetTrashRequest,
|
||||
JobCreateRequest,
|
||||
JobEventResponse,
|
||||
JobListResponse,
|
||||
JobOutputResponse,
|
||||
JobResponse,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UserResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RegisterRequest",
|
||||
"LoginRequest",
|
||||
"UserResponse",
|
||||
"AssetResponse",
|
||||
"AssetTrashRequest",
|
||||
"JobCreateRequest",
|
||||
"JobOutputResponse",
|
||||
"JobEventResponse",
|
||||
"JobResponse",
|
||||
"JobListResponse",
|
||||
]
|
||||
143
backend/app/schemas/schemas.py
Normal file
143
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
return v
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
id: str
|
||||
asset_type: str
|
||||
mime_type: str
|
||||
original_filename: str
|
||||
storage_path: str
|
||||
size_bytes: int
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
thumbnail_path: Optional[str] = None
|
||||
is_trashed: bool = False
|
||||
delete_after_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class JobCreateRequest(BaseModel):
|
||||
mode: str
|
||||
submode: Optional[str] = None
|
||||
prompt: str
|
||||
negative_prompt: Optional[str] = None
|
||||
ground_truth_asset_id: str
|
||||
motion_asset_id: Optional[str] = None
|
||||
audio_asset_id: Optional[str] = None
|
||||
reference_asset_ids: Optional[List[str]] = None
|
||||
pose_asset_id: Optional[str] = None
|
||||
settings: Optional[dict] = None
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
if v not in ("animate", "audio"):
|
||||
raise ValueError("mode must be 'animate' or 'audio'")
|
||||
return v
|
||||
|
||||
@field_validator("submode")
|
||||
@classmethod
|
||||
def validate_submode(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and v not in ("move", "mix"):
|
||||
raise ValueError("submode must be 'move' or 'mix'")
|
||||
return v
|
||||
|
||||
|
||||
class JobOutputResponse(BaseModel):
|
||||
id: str
|
||||
output_type: str
|
||||
file_path: str
|
||||
poster_path: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class JobEventResponse(BaseModel):
|
||||
id: str
|
||||
event_type: str
|
||||
message: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssetTrashRequest(BaseModel):
|
||||
asset_ids: List[str]
|
||||
|
||||
|
||||
class JobResponse(BaseModel):
|
||||
id: str
|
||||
mode: str
|
||||
submode: Optional[str] = None
|
||||
prompt: str
|
||||
negative_prompt: Optional[str] = None
|
||||
status: str
|
||||
comfy_prompt_id: Optional[str] = None
|
||||
workflow_template_name: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
ground_truth_asset_id: Optional[str] = None
|
||||
motion_asset_id: Optional[str] = None
|
||||
audio_asset_id: Optional[str] = None
|
||||
pose_asset_id: Optional[str] = None
|
||||
outputs: List[JobOutputResponse] = []
|
||||
events: List[JobEventResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class JobListResponse(BaseModel):
|
||||
id: str
|
||||
mode: str
|
||||
submode: Optional[str] = None
|
||||
prompt: str
|
||||
error_message: Optional[str] = None
|
||||
status: str
|
||||
ground_truth_asset_id: Optional[str] = None
|
||||
motion_asset_id: Optional[str] = None
|
||||
audio_asset_id: Optional[str] = None
|
||||
pose_asset_id: Optional[str] = None
|
||||
outputs: List[JobOutputResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
128
backend/app/services/comfy_client.py
Normal file
128
backend/app/services/comfy_client.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComfyClient:
|
||||
def __init__(self, base_url: str | None = None):
|
||||
self.base_url = (base_url or settings.COMFYUI_BASE_URL).rstrip("/")
|
||||
self._client = httpx.AsyncClient(timeout=120.0)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
for endpoint in ("/system_stats", "/"):
|
||||
try:
|
||||
response = await self._client.get(f"{self.base_url}{endpoint}")
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("ComfyUI health check failed at %s: %s", endpoint, exc)
|
||||
return False
|
||||
|
||||
async def upload_image(self, file_path: str, filename: str) -> str:
|
||||
with open(file_path, "rb") as handle:
|
||||
files = {"image": (filename, handle, "application/octet-stream")}
|
||||
response = await self._client.post(f"{self.base_url}/upload/image", files=files)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("name", filename)
|
||||
|
||||
async def upload_media(self, file_path: str, filename: str, media_type: str) -> str:
|
||||
endpoint = {
|
||||
"image": "/upload/image",
|
||||
"pose_sheet": "/upload/image",
|
||||
"video": "/upload/video",
|
||||
"audio": "/upload/audio",
|
||||
}.get(media_type)
|
||||
field_name = {
|
||||
"image": "image",
|
||||
"pose_sheet": "image",
|
||||
"video": "video",
|
||||
"audio": "audio",
|
||||
}.get(media_type)
|
||||
|
||||
if not endpoint or not field_name:
|
||||
raise ValueError(f"Unsupported ComfyUI upload media type: {media_type}")
|
||||
|
||||
mime_type = "application/octet-stream"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if media_type in ("image", "pose_sheet"):
|
||||
mime_type = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}.get(suffix, mime_type)
|
||||
elif media_type == "video":
|
||||
mime_type = {
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
}.get(suffix, mime_type)
|
||||
elif media_type == "audio":
|
||||
mime_type = {
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "audio/mp4",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
}.get(suffix, mime_type)
|
||||
|
||||
with open(file_path, "rb") as handle:
|
||||
files = {field_name: (filename, handle, mime_type)}
|
||||
response = await self._client.post(f"{self.base_url}{endpoint}", files=files)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data.get("name", filename)
|
||||
|
||||
async def submit_prompt(self, workflow: Dict[str, Any], client_id: str | None = None) -> str:
|
||||
payload: Dict[str, Any] = {"prompt": workflow}
|
||||
if client_id:
|
||||
payload["client_id"] = client_id
|
||||
response = await self._client.post(f"{self.base_url}/prompt", json=payload)
|
||||
if response.is_error:
|
||||
detail = response.text
|
||||
raise RuntimeError(f"ComfyUI prompt submission failed ({response.status_code}): {detail}")
|
||||
data = response.json()
|
||||
prompt_id = data.get("prompt_id")
|
||||
if not prompt_id:
|
||||
raise RuntimeError(f"No prompt_id returned by ComfyUI: {data}")
|
||||
return prompt_id
|
||||
|
||||
async def get_history(self, prompt_id: str) -> Dict[str, Any]:
|
||||
response = await self._client.get(f"{self.base_url}/history/{prompt_id}")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get(prompt_id, {})
|
||||
|
||||
async def get_history_all(self) -> Dict[str, Any]:
|
||||
response = await self._client.get(f"{self.base_url}/history")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_queue(self) -> Dict[str, Any]:
|
||||
response = await self._client.get(f"{self.base_url}/queue")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_object_info(self, node_name: str) -> Dict[str, Any]:
|
||||
response = await self._client.get(f"{self.base_url}/object_info/{node_name}")
|
||||
response.raise_for_status()
|
||||
return response.json().get(node_name, {})
|
||||
|
||||
async def download_output(self, filename: str, subfolder: str = "", folder_type: str = "output") -> bytes:
|
||||
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
response = await self._client.get(f"{self.base_url}/view", params=params)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
|
||||
comfy_client = ComfyClient()
|
||||
325
backend/app/services/orchestrator.py
Normal file
325
backend/app/services/orchestrator.py
Normal file
@@ -0,0 +1,325 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import Asset, Job, JobEvent, JobOutput
|
||||
from app.services.comfy_client import comfy_client
|
||||
from app.services.storage import asset_storage, output_storage
|
||||
from app.services.workflow_binder import WorkflowBinder, select_template_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".mkv", ".avi"}
|
||||
MODEL_LOADER_INPUTS = {
|
||||
"CLIPLoader": "clip_name",
|
||||
"VAELoader": "vae_name",
|
||||
"UNETLoader": "unet_name",
|
||||
"LoraLoaderModelOnly": "lora_name",
|
||||
}
|
||||
|
||||
|
||||
def _add_event(db: Session, job_id: str, event_type: str, message: str, payload: dict | None = None) -> None:
|
||||
event = JobEvent(
|
||||
job_id=job_id,
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
payload_json=json.dumps(payload) if payload else None,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _set_status(db: Session, job: Job, status: str, error: str | None = None) -> None:
|
||||
job.status = status
|
||||
if error:
|
||||
job.error_message = error
|
||||
db.commit()
|
||||
_add_event(db, job.id, "status_change", f"Job status -> {status}")
|
||||
|
||||
|
||||
def _extract_history_error(history: dict) -> str | None:
|
||||
status = history.get("status", {}) or {}
|
||||
if status.get("status_str") == "error":
|
||||
for message in status.get("messages", []) or []:
|
||||
if not isinstance(message, (list, tuple)) or len(message) < 2:
|
||||
continue
|
||||
payload = message[1] or {}
|
||||
if message[0] == "execution_error":
|
||||
exception_message = payload.get("exception_message")
|
||||
node_id = payload.get("node_id")
|
||||
node_type = payload.get("node_type")
|
||||
if exception_message and node_id and node_type:
|
||||
return f"ComfyUI execution error on node {node_id} ({node_type}): {exception_message}"
|
||||
if exception_message:
|
||||
return f"ComfyUI execution error: {exception_message}"
|
||||
return f"ComfyUI execution error: {payload}"
|
||||
return "ComfyUI execution failed without a detailed error message."
|
||||
|
||||
if history.get("node_errors"):
|
||||
return f"ComfyUI node validation failed: {history['node_errors']}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _output_type_for_filename(filename: str) -> str:
|
||||
return "video" if Path(filename).suffix.lower() in VIDEO_EXTENSIONS else "image"
|
||||
|
||||
|
||||
def _required_model_values(workflow: dict) -> dict[str, set[str]]:
|
||||
required: dict[str, set[str]] = {loader: set() for loader in MODEL_LOADER_INPUTS}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = node.get("class_type")
|
||||
input_name = MODEL_LOADER_INPUTS.get(class_type)
|
||||
if not input_name:
|
||||
continue
|
||||
value = (node.get("inputs") or {}).get(input_name)
|
||||
if isinstance(value, str) and value:
|
||||
required[class_type].add(value)
|
||||
return {loader: values for loader, values in required.items() if values}
|
||||
|
||||
|
||||
async def _validate_runtime_models(workflow: dict) -> None:
|
||||
required = _required_model_values(workflow)
|
||||
if not required:
|
||||
return
|
||||
|
||||
missing_by_loader: list[str] = []
|
||||
for loader, expected_values in required.items():
|
||||
object_info = await comfy_client.get_object_info(loader)
|
||||
loader_input = MODEL_LOADER_INPUTS[loader]
|
||||
available_raw = (((object_info.get("input") or {}).get("required") or {}).get(loader_input) or [[]])[0]
|
||||
available = set(available_raw or [])
|
||||
missing = sorted(value for value in expected_values if value not in available)
|
||||
if missing:
|
||||
missing_by_loader.append(f"{loader} missing {missing}; available={sorted(available)}")
|
||||
|
||||
if missing_by_loader:
|
||||
raise RuntimeError(
|
||||
"ComfyUI runtime is missing required Wan model files. "
|
||||
+ " | ".join(missing_by_loader)
|
||||
)
|
||||
|
||||
|
||||
async def _get_history_with_fallback(prompt_id: str) -> dict:
|
||||
history = await comfy_client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
all_history = await comfy_client.get_history_all()
|
||||
return all_history.get(prompt_id, {})
|
||||
|
||||
|
||||
def _iter_history_files(node_output: dict) -> Iterable[dict]:
|
||||
for video in node_output.get("videos", []) or []:
|
||||
yield {
|
||||
"filename": video["filename"],
|
||||
"subfolder": video.get("subfolder", ""),
|
||||
"folder_type": video.get("type", "output"),
|
||||
"output_type": "video",
|
||||
}
|
||||
|
||||
for image in node_output.get("images", []) or []:
|
||||
filename = image["filename"]
|
||||
yield {
|
||||
"filename": filename,
|
||||
"subfolder": image.get("subfolder", ""),
|
||||
"folder_type": image.get("type", "output"),
|
||||
"output_type": _output_type_for_filename(filename),
|
||||
}
|
||||
|
||||
|
||||
async def _collect_outputs_from_history(db: Session, job: Job, history: dict) -> int:
|
||||
existing_paths = {output.file_path for output in job.outputs}
|
||||
created = 0
|
||||
|
||||
for node_id, node_output in (history.get("outputs", {}) or {}).items():
|
||||
for file_info in _iter_history_files(node_output):
|
||||
fname = file_info["filename"]
|
||||
data = await comfy_client.download_output(
|
||||
fname,
|
||||
file_info["subfolder"],
|
||||
file_info["folder_type"],
|
||||
)
|
||||
rel_path = output_storage.save_bytes(data, job.id, fname)
|
||||
if rel_path in existing_paths:
|
||||
continue
|
||||
|
||||
poster_path = None
|
||||
if file_info["output_type"] == "video":
|
||||
try:
|
||||
poster_fname = f"poster_{Path(fname).stem}.jpg"
|
||||
poster_abs = str(output_storage.absolute_path(f"{job.id}/{poster_fname}"))
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-i", str(output_storage.absolute_path(rel_path)), "-vframes", "1", poster_abs],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if Path(poster_abs).exists():
|
||||
poster_path = f"{job.id}/{poster_fname}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.add(
|
||||
JobOutput(
|
||||
job_id=job.id,
|
||||
output_type=file_info["output_type"],
|
||||
file_path=rel_path,
|
||||
poster_path=poster_path,
|
||||
metadata_json=json.dumps({"node_id": node_id, "filename": fname}),
|
||||
)
|
||||
)
|
||||
existing_paths.add(rel_path)
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
db.commit()
|
||||
|
||||
return created
|
||||
|
||||
|
||||
async def reconcile_job_outputs_if_missing(job_id: str) -> bool:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.query(Job).filter(Job.id == job_id).first()
|
||||
if not job:
|
||||
return False
|
||||
if job.status != "completed" or not job.comfy_prompt_id or job.outputs:
|
||||
return False
|
||||
|
||||
history = await _get_history_with_fallback(job.comfy_prompt_id)
|
||||
if not history or _extract_history_error(history):
|
||||
return False
|
||||
|
||||
created = await _collect_outputs_from_history(db, job, history)
|
||||
if created:
|
||||
_add_event(db, job.id, "outputs_reconciled", f"Recovered {created} output file(s) from ComfyUI history.")
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _upload_asset_to_comfy(db: Session, asset_id: Optional[str]) -> Optional[str]:
|
||||
if not asset_id:
|
||||
return None
|
||||
asset = db.query(Asset).filter(Asset.id == asset_id).first()
|
||||
if not asset:
|
||||
raise ValueError(f"Asset {asset_id} not found")
|
||||
if asset.is_trashed:
|
||||
raise ValueError(f"Asset {asset.original_filename} is in trash")
|
||||
return await comfy_client.upload_media(
|
||||
str(asset_storage.absolute_path(asset.storage_path)),
|
||||
asset.original_filename,
|
||||
asset.asset_type,
|
||||
)
|
||||
|
||||
|
||||
def _validate_job(job: Job) -> list[str]:
|
||||
errors = []
|
||||
if not job.prompt or not job.prompt.strip():
|
||||
errors.append("Prompt is required")
|
||||
if not job.ground_truth_asset_id:
|
||||
errors.append("Ground truth image is required")
|
||||
if job.mode == "animate":
|
||||
if job.submode not in ("move", "mix"):
|
||||
errors.append("Animate mode requires submode 'move' or 'mix'")
|
||||
elif job.mode == "audio":
|
||||
if not job.audio_asset_id:
|
||||
errors.append("Audio mode requires an audio file")
|
||||
else:
|
||||
errors.append("Unknown mode")
|
||||
return errors
|
||||
|
||||
|
||||
async def run_job(job_id: str) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.query(Job).filter(Job.id == job_id).first()
|
||||
if not job:
|
||||
return
|
||||
|
||||
_set_status(db, job, "validating")
|
||||
errors = _validate_job(job)
|
||||
if errors:
|
||||
_set_status(db, job, "failed", "; ".join(errors))
|
||||
return
|
||||
|
||||
_set_status(db, job, "uploading_assets")
|
||||
gt_name = await _upload_asset_to_comfy(db, job.ground_truth_asset_id)
|
||||
motion_name = await _upload_asset_to_comfy(db, job.motion_asset_id)
|
||||
audio_name = await _upload_asset_to_comfy(db, job.audio_asset_id)
|
||||
pose_name = await _upload_asset_to_comfy(db, job.pose_asset_id)
|
||||
ref_names = []
|
||||
if job.reference_asset_ids_json:
|
||||
for ref_id in json.loads(job.reference_asset_ids_json):
|
||||
uploaded = await _upload_asset_to_comfy(db, ref_id)
|
||||
if uploaded:
|
||||
ref_names.append(uploaded)
|
||||
|
||||
settings_dict = json.loads(job.settings_json) if job.settings_json else {}
|
||||
binder = WorkflowBinder(select_template_name(job.mode, job.submode))
|
||||
if "PLACEHOLDER" in binder.status.upper():
|
||||
raise RuntimeError(
|
||||
f"Workflow template '{select_template_name(job.mode, job.submode)}' is still a placeholder. "
|
||||
"Replace it with the production ComfyUI export before running real generations."
|
||||
)
|
||||
raw_seed = settings_dict.get("seed", 0)
|
||||
seed = raw_seed if isinstance(raw_seed, int) and raw_seed >= 0 else 0
|
||||
|
||||
params = {
|
||||
"positive_prompt": job.prompt,
|
||||
"negative_prompt": job.negative_prompt or "",
|
||||
"ground_truth": gt_name,
|
||||
"motion_video": motion_name,
|
||||
"audio": audio_name,
|
||||
"pose_sheet": pose_name,
|
||||
"reference_image": ref_names[0] if ref_names else None,
|
||||
"seed": seed,
|
||||
"steps": settings_dict.get("steps", 20),
|
||||
"cfg": settings_dict.get("cfg", 7.0),
|
||||
}
|
||||
workflow = binder.bind(params)
|
||||
await _validate_runtime_models(workflow)
|
||||
job.workflow_template_name = select_template_name(job.mode, job.submode)
|
||||
job.workflow_template_version = binder.version
|
||||
db.commit()
|
||||
|
||||
_set_status(db, job, "queued")
|
||||
prompt_id = await comfy_client.submit_prompt(workflow, client_id=str(uuid.uuid4()))
|
||||
job.comfy_prompt_id = prompt_id
|
||||
db.commit()
|
||||
_add_event(db, job.id, "submitted", f"ComfyUI prompt_id: {prompt_id}")
|
||||
|
||||
_set_status(db, job, "executing")
|
||||
history = {}
|
||||
for _ in range(360):
|
||||
await asyncio.sleep(5)
|
||||
history = await _get_history_with_fallback(prompt_id)
|
||||
history_error = _extract_history_error(history)
|
||||
if history_error:
|
||||
_set_status(db, job, "failed", history_error)
|
||||
return
|
||||
if history.get("status", {}).get("completed"):
|
||||
break
|
||||
else:
|
||||
_set_status(db, job, "failed", "Timed out waiting for ComfyUI")
|
||||
return
|
||||
|
||||
_set_status(db, job, "collecting_outputs")
|
||||
await _collect_outputs_from_history(db, job, history)
|
||||
_set_status(db, job, "completed")
|
||||
except Exception as exc:
|
||||
logger.exception("Job %s failed: %s", job_id, exc)
|
||||
job = db.query(Job).filter(Job.id == job_id).first()
|
||||
if job:
|
||||
_set_status(db, job, "failed", str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
118
backend/app/services/storage.py
Normal file
118
backend/app/services/storage.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
from fastapi import UploadFile
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class LocalStorageService:
|
||||
def __init__(self, root: str):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def save_upload(self, upload: UploadFile, subfolder: str) -> tuple[str, int]:
|
||||
dest_dir = self.root / subfolder
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(upload.filename or "file").suffix
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
dest_path = dest_dir / filename
|
||||
content = await upload.read()
|
||||
async with aiofiles.open(dest_path, "wb") as handle:
|
||||
await handle.write(content)
|
||||
return str(dest_path.relative_to(self.root)).replace("\\", "/"), len(content)
|
||||
|
||||
def save_bytes(self, data: bytes, subfolder: str, filename: str) -> str:
|
||||
dest_dir = self.root / subfolder
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_path = dest_dir / filename
|
||||
with open(dest_path, "wb") as handle:
|
||||
handle.write(data)
|
||||
return str(dest_path.relative_to(self.root)).replace("\\", "/")
|
||||
|
||||
def absolute_path(self, relative_path: str) -> Path:
|
||||
return self.root / relative_path
|
||||
|
||||
def delete_relative_path(self, relative_path: Optional[str]) -> None:
|
||||
if not relative_path:
|
||||
return
|
||||
abs_path = self.absolute_path(relative_path)
|
||||
try:
|
||||
if abs_path.exists():
|
||||
abs_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def generate_thumbnail(self, image_path: str, thumb_subfolder: str) -> Optional[str]:
|
||||
try:
|
||||
abs_path = self.absolute_path(image_path)
|
||||
with Image.open(abs_path) as img:
|
||||
img.thumbnail((400, 400))
|
||||
thumb_dir = self.root / thumb_subfolder
|
||||
thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||
thumb_name = f"thumb_{Path(image_path).stem}.jpg"
|
||||
thumb_path = thumb_dir / thumb_name
|
||||
img.convert("RGB").save(thumb_path, "JPEG", quality=80)
|
||||
return str(thumb_path.relative_to(self.root)).replace("\\", "/")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def generate_video_thumbnail(self, video_path: str, thumb_subfolder: str) -> Optional[str]:
|
||||
abs_path = self.absolute_path(video_path)
|
||||
thumb_dir = self.root / thumb_subfolder
|
||||
thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||
thumb_name = f"thumb_{Path(video_path).stem}.jpg"
|
||||
thumb_path = thumb_dir / thumb_name
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(abs_path),
|
||||
"-ss",
|
||||
"00:00:00.500",
|
||||
"-vframes",
|
||||
"1",
|
||||
str(thumb_path),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if thumb_path.exists():
|
||||
return str(thumb_path.relative_to(self.root)).replace("\\", "/")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def detect_duration_seconds(self, relative_path: str) -> Optional[float]:
|
||||
abs_path = self.absolute_path(relative_path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(abs_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=True,
|
||||
)
|
||||
return round(float(result.stdout.strip()), 3)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
asset_storage = LocalStorageService(settings.ASSET_STORAGE_ROOT)
|
||||
output_storage = LocalStorageService(settings.OUTPUT_STORAGE_ROOT)
|
||||
64
backend/app/services/workflow_binder.py
Normal file
64
backend/app/services/workflow_binder.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKFLOWS_ROOT = Path(__file__).parents[3] / "workflows"
|
||||
_REGISTRY: Dict[str, Path] = {}
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
_REGISTRY.clear()
|
||||
for path in WORKFLOWS_ROOT.rglob("*.json"):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
meta = data.get("__animatrix_meta__", {})
|
||||
_REGISTRY[meta.get("name") or path.stem] = path
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load workflow %s: %s", path, exc)
|
||||
|
||||
|
||||
_discover()
|
||||
|
||||
|
||||
def select_template_name(mode: str, submode: Optional[str]) -> str:
|
||||
if mode == "animate":
|
||||
return f"wan22_animate_{submode or 'move'}"
|
||||
if mode == "audio":
|
||||
return "wan22_s2v"
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
|
||||
|
||||
class WorkflowBinder:
|
||||
def __init__(self, template_name: str):
|
||||
if template_name not in _REGISTRY:
|
||||
_discover()
|
||||
if template_name not in _REGISTRY:
|
||||
raise FileNotFoundError(
|
||||
f"Workflow template '{template_name}' not found in {WORKFLOWS_ROOT}. Available: {list(_REGISTRY.keys())}"
|
||||
)
|
||||
with open(_REGISTRY[template_name], encoding="utf-8") as handle:
|
||||
self._raw = json.load(handle)
|
||||
self._meta = self._raw.get("__animatrix_meta__", {})
|
||||
self._param_nodes = self._meta.get("param_nodes", {})
|
||||
self.version = self._meta.get("version", "unknown")
|
||||
self.status = self._meta.get("status", "")
|
||||
|
||||
def bind(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
workflow = copy.deepcopy(self._raw)
|
||||
workflow.pop("__animatrix_meta__", None)
|
||||
for param_key, value in params.items():
|
||||
if value is None:
|
||||
continue
|
||||
node_spec = self._param_nodes.get(param_key)
|
||||
if not node_spec:
|
||||
continue
|
||||
node_id = str(node_spec["node_id"])
|
||||
input_name = node_spec["input"]
|
||||
if node_id in workflow:
|
||||
workflow[node_id]["inputs"][input_name] = value
|
||||
return workflow
|
||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
sqlalchemy==2.0.30
|
||||
alembic==1.13.1
|
||||
pydantic==2.7.1
|
||||
pydantic-settings==2.2.1
|
||||
passlib==1.7.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
aiofiles==23.2.1
|
||||
Pillow==11.2.1
|
||||
python-dotenv==1.0.1
|
||||
5
backend/run.py
Normal file
5
backend/run.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import uvicorn
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
0
backend/storage/.gitkeep
Normal file
0
backend/storage/.gitkeep
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
6
frontend/next.config.js
Normal file
6
frontend/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
2167
frontend/package-lock.json
generated
Normal file
2167
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "animatrix-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.5.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.5",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
45
frontend/src/app/dashboard/error.tsx
Normal file
45
frontend/src/app/dashboard/error.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Animatrix dashboard route failed:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="panel p-6 sm:p-7">
|
||||
<p className="eyebrow">Dashboard</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-white">Dashboard Runtime Error</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-subtext">
|
||||
The dashboard hit a client-side render error. This route now fails closed with a local fallback instead of collapsing the entire application shell.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 rounded-[22px] border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{error.message || "Unknown dashboard error"}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button className="btn-primary" onClick={reset} type="button">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retry dashboard
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => router.push("/login")} type="button">
|
||||
Go to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/dashboard/page.tsx
Normal file
5
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardClient } from "@/components/dashboard-client";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
199
frontend/src/app/globals.css
Normal file
199
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,199 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ink: #05070b;
|
||||
--panel: rgba(13, 17, 24, 0.82);
|
||||
--panel-strong: rgba(17, 22, 31, 0.94);
|
||||
--soft: rgba(255, 255, 255, 0.05);
|
||||
--soft-strong: rgba(255, 255, 255, 0.08);
|
||||
--edge: rgba(255, 255, 255, 0.1);
|
||||
--text: #f5f7fb;
|
||||
--subtext: #97a3b6;
|
||||
--accent: #72f4b7;
|
||||
--accent-strong: #d0ffd9;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ink text-text antialiased;
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, rgba(114, 244, 183, 0.16), transparent 22%),
|
||||
radial-gradient(circle at 88% 12%, rgba(90, 139, 255, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #0a0d12 0%, #05070b 42%, #040508 100%);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
mask-image: radial-gradient(circle at center, black 48%, transparent 92%);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
background: rgba(114, 244, 183, 0.3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto w-full max-w-[90rem] px-5 py-8 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-[30px] border border-white/10 shadow-glow backdrop-blur-xl;
|
||||
background: linear-gradient(180deg, rgba(18, 23, 31, 0.94) 0%, rgba(12, 16, 23, 0.88) 100%);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-[22px] border border-white/10 px-4 py-3.5 text-sm text-text outline-none transition;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgba(114, 244, 183, 0.45);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
0 0 0 3px rgba(114, 244, 183, 0.08);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-[20px] px-4 py-3 text-sm font-medium transition;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn text-black;
|
||||
background: linear-gradient(135deg, #72f4b7 0%, #d0ffd9 100%);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.15) inset,
|
||||
0 18px 36px rgba(114, 244, 183, 0.18);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn border border-white/10 text-text hover:bg-white/5;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-2 rounded-[18px] border border-white/10 px-3 py-2 text-xs text-text;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-[11px] uppercase tracking-[0.28em] text-subtext;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply rounded-[24px] border border-white/10 p-4;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-xl font-semibold text-text sm:text-2xl;
|
||||
}
|
||||
|
||||
.muted {
|
||||
@apply text-sm text-subtext;
|
||||
}
|
||||
|
||||
.glass-overlay {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 24, 34, 0.88) 0%, rgba(10, 14, 20, 0.82) 100%);
|
||||
box-shadow:
|
||||
0 28px 90px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.video-slider {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(114, 244, 183, 0.78), rgba(255, 255, 255, 0.22));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.video-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: #f5f7fb;
|
||||
box-shadow: 0 0 0 6px rgba(114, 244, 183, 0.12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: #f5f7fb;
|
||||
box-shadow: 0 0 0 6px rgba(114, 244, 183, 0.12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@apply inline-block h-2.5 w-2.5 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
53
frontend/src/app/jobs/[id]/error.tsx
Normal file
53
frontend/src/app/jobs/[id]/error.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function JobDetailError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Animatrix job detail route failed:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/65 px-4 py-6 backdrop-blur-md sm:px-6 lg:px-10">
|
||||
<div className="glass-overlay w-full max-w-2xl rounded-[34px] border border-white/12 p-6 shadow-glow sm:p-8">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="eyebrow">Generation Detail</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-white">Client Runtime Error</h1>
|
||||
</div>
|
||||
<button className="btn-secondary h-11 w-11 rounded-full p-0" onClick={() => router.push("/dashboard")} type="button">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-6 text-subtext">
|
||||
The detail overlay hit a client-side error. The route now has a guarded fallback instead of breaking the whole app shell.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 rounded-[22px] border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{error.message || "Unknown client-side exception"}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button className="btn-primary" onClick={reset} type="button">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => router.push("/dashboard")} type="button">
|
||||
Back to dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
frontend/src/app/jobs/[id]/page.tsx
Normal file
176
frontend/src/app/jobs/[id]/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Download, RefreshCw, X } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { apiGet, apiUrl } from "@/lib/api";
|
||||
import { formatIstDateTime } from "@/lib/time";
|
||||
import type { Job } from "@/lib/types";
|
||||
|
||||
export default function JobPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await apiGet<Job>(`/api/jobs/${params.id}`);
|
||||
setJob(data);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load generation detail");
|
||||
if ((err as Error).message?.includes("Not authenticated")) {
|
||||
router.push("/login");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [params.id, router]);
|
||||
|
||||
const primaryOutput = useMemo(() => job?.outputs?.[0] ?? null, [job]);
|
||||
const videoOutput = useMemo(
|
||||
() => job?.outputs?.find((output) => output.output_type === "video") ?? primaryOutput,
|
||||
[job, primaryOutput]
|
||||
);
|
||||
const outputs = job?.outputs ?? [];
|
||||
const events = job?.events ?? [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 overflow-y-auto bg-black/65 px-4 py-6 backdrop-blur-md sm:px-6 lg:px-10">
|
||||
<div className="mx-auto max-w-[92rem]">
|
||||
<div className="glass-overlay rounded-[34px] border border-white/12 p-4 shadow-glow sm:p-6">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="eyebrow">Generation Detail</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-white sm:text-4xl">Job {params.id}</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-subtext">
|
||||
Review the generated output, runtime events, and download the final render from this overlay without leaving the dashboard flow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-secondary h-11 w-11 rounded-full p-0" onClick={() => void load()} type="button">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="btn-secondary h-11 w-11 rounded-full p-0" onClick={() => router.push("/dashboard")} type="button">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">{error}</div> : null}
|
||||
|
||||
{!job ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-10 text-subtext">Loading generation detail...</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<div className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusChip status={job.status} />
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-subtext">{job.workflow_template_name ?? "workflow pending"}</div>
|
||||
</div>
|
||||
{videoOutput ? (
|
||||
<a className="btn-secondary" href={apiUrl(`/api/jobs/${job.id}/outputs/${videoOutput.id}/download`)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{videoOutput?.output_type === "video" ? (
|
||||
<VideoPlayer
|
||||
poster={videoOutput.poster_path ? apiUrl(`/storage/outputs/${videoOutput.poster_path}`) : null}
|
||||
src={apiUrl(`/storage/outputs/${videoOutput.file_path}`)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center rounded-[28px] border border-white/10 bg-black/20 text-subtext">
|
||||
No output video yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="mb-3 text-[11px] uppercase tracking-[0.24em] text-subtext">Prompt</div>
|
||||
<p className="text-sm leading-7 text-white/90">{job.prompt}</p>
|
||||
{job.negative_prompt ? (
|
||||
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.24em] text-subtext">Negative Prompt</div>
|
||||
<div className="mt-2 text-sm text-subtext">{job.negative_prompt}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{job.error_message ? (
|
||||
<div className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{job.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="mb-4 text-[11px] uppercase tracking-[0.24em] text-subtext">Output Files</div>
|
||||
<div className="space-y-3">
|
||||
{outputs.length === 0 ? <div className="text-sm text-subtext">No outputs persisted yet.</div> : null}
|
||||
{outputs.map((output) => (
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3" key={output.id}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{output.output_type}</div>
|
||||
<div className="mt-1 text-xs text-subtext">{formatIstDateTime(output.created_at)}</div>
|
||||
</div>
|
||||
<a className="btn-secondary" href={apiUrl(`/api/jobs/${job.id}/outputs/${output.id}/download`)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-semibold text-white">Runtime Events</div>
|
||||
<div className="text-sm text-subtext">{events.length} event{events.length === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{events.map((event) => (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4" key={event.id}>
|
||||
<div className="mb-2 flex items-start justify-between gap-4">
|
||||
<div className="text-sm font-medium text-white">{event.event_type}</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-subtext">{formatIstDateTime(event.created_at)}</div>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-subtext">{event.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{events.length === 0 ? <div className="text-sm text-subtext">No runtime events available yet.</div> : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const tone =
|
||||
status === "completed"
|
||||
? "border-emerald-400/25 bg-emerald-400/10 text-emerald-200"
|
||||
: status === "failed"
|
||||
? "border-red-400/25 bg-red-400/10 text-red-200"
|
||||
: "border-amber-300/25 bg-amber-300/10 text-amber-100";
|
||||
|
||||
return <div className={`chip ${tone}`}>{status}</div>;
|
||||
}
|
||||
15
frontend/src/app/layout.tsx
Normal file
15
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Animatrix",
|
||||
description: "Animatrix generation workspace"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="app-root">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/login/page.tsx
Normal file
5
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthForm mode="login" />;
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
5
frontend/src/app/register/page.tsx
Normal file
5
frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <AuthForm mode="register" />;
|
||||
}
|
||||
92
frontend/src/components/auth-form.tsx
Normal file
92
frontend/src/components/auth-form.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { apiPost } from "@/lib/api";
|
||||
|
||||
export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const path = mode === "login" ? "/api/auth/login" : "/api/auth/register";
|
||||
await apiPost(path, { email, password });
|
||||
if (mode === "register") {
|
||||
await apiPost("/api/auth/login", { email, password });
|
||||
}
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Request failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shell flex min-h-screen items-center justify-center py-14">
|
||||
<section className="panel w-full max-w-md p-8 sm:p-10">
|
||||
<div className="mb-8">
|
||||
<p className="eyebrow">Animatrix</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-white">
|
||||
{mode === "login" ? "Sign in" : "Create account"}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-subtext">
|
||||
{mode === "login"
|
||||
? "Use your workspace account to open the generator."
|
||||
: "Create a local account for this workspace."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button className="btn-primary w-full" disabled={busy} type="submit">
|
||||
{busy ? "Working..." : mode === "login" ? "Sign in" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-subtext">
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
No account yet? <Link className="text-accent" href="/register">Register</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already registered? <Link className="text-accent" href="/login">Sign in</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1161
frontend/src/components/dashboard-client.tsx
Normal file
1161
frontend/src/components/dashboard-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
165
frontend/src/components/video-player.tsx
Normal file
165
frontend/src/components/video-player.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Maximize2, Pause, Play, Volume2, VolumeX } from "lucide-react";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "0:00";
|
||||
}
|
||||
const rounded = Math.floor(seconds);
|
||||
const mins = Math.floor(rounded / 60);
|
||||
const secs = rounded % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ src, poster }: { src: string; poster?: string | null }) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const syncState = () => {
|
||||
setCurrentTime(video.currentTime || 0);
|
||||
setDuration(video.duration || 0);
|
||||
setProgress(video.duration ? (video.currentTime / video.duration) * 100 : 0);
|
||||
setIsPlaying(!video.paused && !video.ended);
|
||||
setIsMuted(video.muted);
|
||||
setVolume(video.volume);
|
||||
};
|
||||
|
||||
syncState();
|
||||
video.addEventListener("timeupdate", syncState);
|
||||
video.addEventListener("loadedmetadata", syncState);
|
||||
video.addEventListener("play", syncState);
|
||||
video.addEventListener("pause", syncState);
|
||||
video.addEventListener("volumechange", syncState);
|
||||
video.addEventListener("ended", syncState);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", syncState);
|
||||
video.removeEventListener("loadedmetadata", syncState);
|
||||
video.removeEventListener("play", syncState);
|
||||
video.removeEventListener("pause", syncState);
|
||||
video.removeEventListener("volumechange", syncState);
|
||||
video.removeEventListener("ended", syncState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const progressLabel = useMemo(
|
||||
() => `${formatDuration(currentTime)} / ${formatDuration(duration)}`,
|
||||
[currentTime, duration]
|
||||
);
|
||||
|
||||
const togglePlayback = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
await video.play();
|
||||
return;
|
||||
}
|
||||
video.pause();
|
||||
};
|
||||
|
||||
const handleSeek = (value: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !video.duration) return;
|
||||
const nextTime = (value / 100) * video.duration;
|
||||
video.currentTime = nextTime;
|
||||
setProgress(value);
|
||||
setCurrentTime(nextTime);
|
||||
};
|
||||
|
||||
const handleVolume = (value: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const nextVolume = value / 100;
|
||||
video.volume = nextVolume;
|
||||
video.muted = nextVolume === 0;
|
||||
setVolume(nextVolume);
|
||||
setIsMuted(video.muted);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(video.muted);
|
||||
};
|
||||
|
||||
const enterFullscreen = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
await video.requestFullscreen();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-[rgba(255,255,255,0.03)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]">
|
||||
<div className="aspect-video overflow-hidden bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-contain"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={poster ?? undefined}
|
||||
src={src}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<input
|
||||
aria-label="Playback progress"
|
||||
className="video-slider"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) => handleSeek(Number(event.target.value))}
|
||||
type="range"
|
||||
value={progress}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-subtext">
|
||||
<span>{progressLabel}</span>
|
||||
<span>MP4 output</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className="btn-secondary h-11 min-w-11 rounded-full p-0" onClick={() => void togglePlayback()} type="button">
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<button className="btn-secondary h-11 min-w-11 rounded-full p-0" onClick={toggleMute} type="button">
|
||||
{isMuted || volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<div className="min-w-[8rem] flex-1 sm:max-w-44">
|
||||
<input
|
||||
aria-label="Volume"
|
||||
className="video-slider"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) => handleVolume(Number(event.target.value))}
|
||||
type="range"
|
||||
value={Math.round((isMuted ? 0 : volume) * 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary h-11 min-w-11 rounded-full p-0" onClick={() => void enterFullscreen()} type="button">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/lib/api.ts
Normal file
44
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const rawBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ?? "";
|
||||
|
||||
export const API_BASE_URL = rawBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalized}`;
|
||||
}
|
||||
|
||||
async function parse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Request failed with ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: "include",
|
||||
cache: "no-store"
|
||||
});
|
||||
return parse<T>(response);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body: BodyInit | object, isForm = false): Promise<T> {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: isForm ? undefined : { "Content-Type": "application/json" },
|
||||
body: isForm ? (body as BodyInit) : JSON.stringify(body)
|
||||
});
|
||||
return parse<T>(response);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(path: string, body?: object): Promise<T> {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return parse<T>(response);
|
||||
}
|
||||
33
frontend/src/lib/time.ts
Normal file
33
frontend/src/lib/time.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const IST_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Kolkata",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
function normalizeDateInput(value: string | Date): Date {
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return new Date(Number.NaN);
|
||||
}
|
||||
|
||||
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
|
||||
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(normalized);
|
||||
return new Date(hasTimezone ? normalized : `${normalized}Z`);
|
||||
}
|
||||
|
||||
export function formatIstDateTime(value: string | Date): string {
|
||||
const date = normalizeDateInput(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
return `${IST_FORMATTER.format(date)} IST`;
|
||||
}
|
||||
69
frontend/src/lib/types.ts
Normal file
69
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
asset_type: string;
|
||||
mime_type: string;
|
||||
original_filename: string;
|
||||
storage_path: string;
|
||||
size_bytes: number;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
duration_seconds?: number | null;
|
||||
thumbnail_path?: string | null;
|
||||
is_trashed?: boolean;
|
||||
delete_after_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type JobOutput = {
|
||||
id: string;
|
||||
output_type: string;
|
||||
file_path: string;
|
||||
poster_path?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type JobEvent = {
|
||||
id: string;
|
||||
event_type: string;
|
||||
message?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
mode: string;
|
||||
submode?: string | null;
|
||||
prompt: string;
|
||||
negative_prompt?: string | null;
|
||||
status: string;
|
||||
comfy_prompt_id?: string | null;
|
||||
workflow_template_name?: string | null;
|
||||
error_message?: string | null;
|
||||
ground_truth_asset_id?: string | null;
|
||||
motion_asset_id?: string | null;
|
||||
audio_asset_id?: string | null;
|
||||
pose_asset_id?: string | null;
|
||||
reference_asset_ids?: string[] | null;
|
||||
outputs: JobOutput[];
|
||||
events: JobEvent[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AdminHealth = {
|
||||
api: string;
|
||||
comfyui: boolean;
|
||||
};
|
||||
|
||||
export type JobsSummary = {
|
||||
total: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
};
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
21
frontend/tailwind.config.js
Normal file
21
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#0a0a0d",
|
||||
panel: "#141419",
|
||||
soft: "#1c1d24",
|
||||
edge: "#2a2c35",
|
||||
text: "#f2f4f8",
|
||||
subtext: "#99a1b3",
|
||||
accent: "#1ed760"
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 0 1px rgba(255,255,255,0.06), 0 20px 80px rgba(0,0,0,0.35)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
15
infra/animatrix-backend.service
Normal file
15
infra/animatrix-backend.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Animatrix Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=desineuron-node-01
|
||||
WorkingDirectory=/opt/animatrix/backend
|
||||
Environment=PATH=/opt/animatrix/backend/.venv/bin
|
||||
ExecStart=/opt/animatrix/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8200
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
17
infra/animatrix-frontend.service
Normal file
17
infra/animatrix-frontend.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Animatrix Frontend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=desineuron-node-01
|
||||
WorkingDirectory=/opt/animatrix/frontend/.next/standalone
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOSTNAME=127.0.0.1
|
||||
Environment=PORT=3200
|
||||
ExecStart=/usr/bin/node /opt/animatrix/frontend/.next/standalone/server.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
62
infra/animatrix.desineuron.in.nginx.conf
Normal file
62
infra/animatrix.desineuron.in.nginx.conf
Normal file
@@ -0,0 +1,62 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name animatrix.desineuron.in;
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name animatrix.desineuron.in;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/desineuron-infra/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/desineuron-infra/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 1G;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8200/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:8200/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /storage/ {
|
||||
proxy_pass http://127.0.0.1:8200/storage/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3200;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
}
|
||||
}
|
||||
21
scripts/build_frontend_standalone.sh
Normal file
21
scripts/build_frontend_standalone.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
STANDALONE_DIR="$FRONTEND_DIR/.next/standalone"
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
npm run build
|
||||
|
||||
mkdir -p "$STANDALONE_DIR/.next"
|
||||
rm -rf "$STANDALONE_DIR/.next/static"
|
||||
cp -R "$FRONTEND_DIR/.next/static" "$STANDALONE_DIR/.next/static"
|
||||
|
||||
if [ -d "$FRONTEND_DIR/public" ]; then
|
||||
rm -rf "$STANDALONE_DIR/public"
|
||||
cp -R "$FRONTEND_DIR/public" "$STANDALONE_DIR/public"
|
||||
fi
|
||||
|
||||
echo "Standalone frontend prepared at $STANDALONE_DIR"
|
||||
80
workflows/animate/wan22_animate_mix_v1.json
Normal file
80
workflows/animate/wan22_animate_mix_v1.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"__animatrix_meta__": {
|
||||
"name": "wan22_animate_mix",
|
||||
"version": "1",
|
||||
"model": "Wan2.2-Animate-14B",
|
||||
"description": "Character animation — Mix mode. Blends reference character(s) with ground-truth following a motion video.",
|
||||
"param_nodes": {
|
||||
"positive_prompt": {"node_id": "6", "input": "text"},
|
||||
"negative_prompt": {"node_id": "7", "input": "text"},
|
||||
"ground_truth": {"node_id": "12", "input": "image"},
|
||||
"motion_video": {"node_id": "15", "input": "video"},
|
||||
"reference_image": {"node_id": "20", "input": "image"},
|
||||
"pose_sheet": {"node_id": "22", "input": "image"},
|
||||
"seed": {"node_id": "25", "input": "seed"},
|
||||
"steps": {"node_id": "25", "input": "steps"},
|
||||
"cfg": {"node_id": "25", "input": "cfg"}
|
||||
},
|
||||
"status": "PLACEHOLDER — replace with production ComfyUI export"
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a character performing a motion",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "blurry, low quality, watermark, deformed",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"12": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "ground_truth.png"
|
||||
}
|
||||
},
|
||||
"15": {
|
||||
"class_type": "VHS_LoadVideo",
|
||||
"inputs": {
|
||||
"video": "motion_video.mp4",
|
||||
"force_rate": 0,
|
||||
"force_size": "Disabled",
|
||||
"custom_width": 512,
|
||||
"custom_height": 512,
|
||||
"frame_load_cap": 0,
|
||||
"skip_first_frames": 0,
|
||||
"select_every_nth": 1
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "reference.png"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "pose_sheet.png"
|
||||
}
|
||||
},
|
||||
"25": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": -1,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
"denoise": 1.0,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["30", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
234
workflows/animate/wan22_animate_move_v1.json
Normal file
234
workflows/animate/wan22_animate_move_v1.json
Normal file
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"__animatrix_meta__": {
|
||||
"name": "wan22_animate_move",
|
||||
"version": "5",
|
||||
"model": "Wan2.2 I2V A14B Local Native",
|
||||
"description": "Official local Comfy-native Wan 2.2 image-to-video runtime using repackaged high-noise and low-noise diffusion models on GPU NVMe.",
|
||||
"param_nodes": {
|
||||
"positive_prompt": { "node_id": "2", "input": "text" },
|
||||
"negative_prompt": { "node_id": "3", "input": "text" },
|
||||
"ground_truth": { "node_id": "1", "input": "image" },
|
||||
"seed": { "node_id": "10", "input": "noise_seed" },
|
||||
"width": { "node_id": "12", "input": "width" },
|
||||
"height": { "node_id": "12", "input": "height" },
|
||||
"length": { "node_id": "12", "input": "length" }
|
||||
},
|
||||
"status": "production_local_native_graph"
|
||||
},
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "ground_truth.png"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "cinematic character animation from a grounded first frame",
|
||||
"clip": [
|
||||
"4",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "oversaturated, overexposed, static frame, blurry details, unclear details, watermark, messy background, low quality, jpeg artifacts, deformed limbs, extra fingers, ugly, distorted face, character drift",
|
||||
"clip": [
|
||||
"4",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
|
||||
"type": "wan",
|
||||
"device": "default"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "wan_2.1_vae.safetensors"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
|
||||
"weight_dtype": "default"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
|
||||
"weight_dtype": "default"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"lora_name": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
|
||||
"strength_model": 1.0
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"lora_name": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
|
||||
"strength_model": 1.0
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "ModelSamplingSD3",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"8",
|
||||
0
|
||||
],
|
||||
"shift": 5.0
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"class_type": "ModelSamplingSD3",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"9",
|
||||
0
|
||||
],
|
||||
"shift": 5.0
|
||||
}
|
||||
},
|
||||
"12": {
|
||||
"class_type": "WanImageToVideo",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"5",
|
||||
0
|
||||
],
|
||||
"width": 832,
|
||||
"height": 468,
|
||||
"length": 81,
|
||||
"batch_size": 1,
|
||||
"start_image": [
|
||||
"1",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"add_noise": "enable",
|
||||
"noise_seed": 42,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": [
|
||||
"12",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"12",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"12",
|
||||
2
|
||||
],
|
||||
"start_at_step": 0,
|
||||
"end_at_step": 2,
|
||||
"return_with_leftover_noise": "enable"
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"11",
|
||||
0
|
||||
],
|
||||
"add_noise": "disable",
|
||||
"noise_seed": 42,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": [
|
||||
"12",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"12",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"13",
|
||||
0
|
||||
],
|
||||
"start_at_step": 2,
|
||||
"end_at_step": 4,
|
||||
"return_with_leftover_noise": "disable"
|
||||
}
|
||||
},
|
||||
"15": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"class_type": "CreateVideo",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"15",
|
||||
0
|
||||
],
|
||||
"fps": 16
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {
|
||||
"video": [
|
||||
"16",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "Animatrix",
|
||||
"format": "mp4",
|
||||
"codec": "h264"
|
||||
}
|
||||
}
|
||||
}
|
||||
74
workflows/s2v/wan22_s2v_v1.json
Normal file
74
workflows/s2v/wan22_s2v_v1.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"__animatrix_meta__": {
|
||||
"name": "wan22_s2v",
|
||||
"version": "1",
|
||||
"model": "Wan2.2-S2V-14B",
|
||||
"description": "Audio Performance Studio — drives character animation from an audio file.",
|
||||
"param_nodes": {
|
||||
"positive_prompt": {"node_id": "6", "input": "text"},
|
||||
"negative_prompt": {"node_id": "7", "input": "text"},
|
||||
"ground_truth": {"node_id": "12", "input": "image"},
|
||||
"audio": {"node_id": "16", "input": "audio"},
|
||||
"reference_image": {"node_id": "20", "input": "image"},
|
||||
"pose_sheet": {"node_id": "22", "input": "image"},
|
||||
"seed": {"node_id": "25", "input": "seed"},
|
||||
"steps": {"node_id": "25", "input": "steps"},
|
||||
"cfg": {"node_id": "25", "input": "cfg"}
|
||||
},
|
||||
"status": "PLACEHOLDER — replace with production ComfyUI export"
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a character singing expressively",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "blurry, low quality, watermark, static",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"12": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "ground_truth.png"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"class_type": "VHS_LoadAudio",
|
||||
"inputs": {
|
||||
"audio": "audio.mp3",
|
||||
"seek_seconds": 0
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "reference.png"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "pose_sheet.png"
|
||||
}
|
||||
},
|
||||
"25": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": -1,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "karras",
|
||||
"denoise": 1.0,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["30", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user