Initial Animatrix import

This commit is contained in:
Sagnik
2026-04-17 19:11:57 +05:30
commit c7994d17a9
60 changed files with 8516 additions and 0 deletions

18
.gitignore vendored Normal file
View 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
View 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

View File

@@ -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.`

File diff suppressed because it is too large Load Diff

64
Docs/RUNBOOK.md Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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}

View 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(),
}

View 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

View 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))

View 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
View 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

View 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
View 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
View 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
View 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"}

View File

@@ -0,0 +1,3 @@
from app.models.models import Asset, Job, JobEvent, JobOutput, User
__all__ = ["User", "Asset", "Job", "JobOutput", "JobEvent"]

View 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")

View 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",
]

View 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

View 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()

View 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()

View 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)

View 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
View 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
View 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
View File

6
frontend/next-env.d.ts vendored Normal file
View 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
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;

2167
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { DashboardClient } from "@/components/dashboard-client";
export default function DashboardPage() {
return <DashboardClient />;
}

View 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);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { AuthForm } from "@/components/auth-form";
export default function LoginPage() {
return <AuthForm mode="login" />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,5 @@
import { AuthForm } from "@/components/auth-form";
export default function RegisterPage() {
return <AuthForm mode="register" />;
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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;
};

View 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))
}

View 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
View 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"]
}

View 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

View 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

View 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;
}
}

View 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"

View 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]
}
}
}

View 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"
}
}
}

View 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]
}
}
}