From c7994d17a9077045fb47abf1d32ef1499e1c5b46 Mon Sep 17 00:00:00 2001 From: Sagnik Date: Fri, 17 Apr 2026 19:11:57 +0530 Subject: [PATCH] Initial Animatrix import --- .gitignore | 18 + Animatrix Project Truth.md | 132 + ...mpt for Coding Agent - First Principles.md | 647 +++++ ...ix Monolithic SRS - Wan 2.2 Flow Studio.md | 1237 ++++++++++ Docs/RUNBOOK.md | 64 + README.md | 103 + backend/.env.example | 11 + backend/app/__init__.py | 1 + backend/app/api/routes/__init__.py | 1 + backend/app/api/routes/admin.py | 28 + backend/app/api/routes/assets.py | 120 + backend/app/api/routes/auth.py | 61 + backend/app/api/routes/jobs.py | 118 + backend/app/core/config.py | 25 + backend/app/core/deps.py | 30 + backend/app/core/security.py | 34 + backend/app/db/init_db.py | 63 + backend/app/db/session.py | 23 + backend/app/main.py | 32 + backend/app/models/__init__.py | 3 + backend/app/models/models.py | 106 + backend/app/schemas/__init__.py | 25 + backend/app/schemas/schemas.py | 143 ++ backend/app/services/comfy_client.py | 128 + backend/app/services/orchestrator.py | 325 +++ backend/app/services/storage.py | 118 + backend/app/services/workflow_binder.py | 64 + backend/requirements.txt | 13 + backend/run.py | 5 + backend/storage/.gitkeep | 0 frontend/next-env.d.ts | 6 + frontend/next.config.js | 6 + frontend/package-lock.json | 2167 +++++++++++++++++ frontend/package.json | 27 + frontend/postcss.config.js | 6 + frontend/src/app/dashboard/error.tsx | 45 + frontend/src/app/dashboard/page.tsx | 5 + frontend/src/app/globals.css | 199 ++ frontend/src/app/jobs/[id]/error.tsx | 53 + frontend/src/app/jobs/[id]/page.tsx | 176 ++ frontend/src/app/layout.tsx | 15 + frontend/src/app/login/page.tsx | 5 + frontend/src/app/page.tsx | 5 + frontend/src/app/register/page.tsx | 5 + frontend/src/components/auth-form.tsx | 92 + frontend/src/components/dashboard-client.tsx | 1161 +++++++++ frontend/src/components/video-player.tsx | 165 ++ frontend/src/lib/api.ts | 44 + frontend/src/lib/time.ts | 33 + frontend/src/lib/types.ts | 69 + frontend/src/lib/utils.ts | 6 + frontend/tailwind.config.js | 21 + frontend/tsconfig.json | 24 + infra/animatrix-backend.service | 15 + infra/animatrix-frontend.service | 17 + infra/animatrix.desineuron.in.nginx.conf | 62 + scripts/build_frontend_standalone.sh | 21 + workflows/animate/wan22_animate_mix_v1.json | 80 + workflows/animate/wan22_animate_move_v1.json | 234 ++ workflows/s2v/wan22_s2v_v1.json | 74 + 60 files changed, 8516 insertions(+) create mode 100644 .gitignore create mode 100644 Animatrix Project Truth.md create mode 100644 Docs/Animatrix Build Prompt for Coding Agent - First Principles.md create mode 100644 Docs/Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md create mode 100644 Docs/RUNBOOK.md create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/routes/__init__.py create mode 100644 backend/app/api/routes/admin.py create mode 100644 backend/app/api/routes/assets.py create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/jobs.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/init_db.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/models.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/schemas.py create mode 100644 backend/app/services/comfy_client.py create mode 100644 backend/app/services/orchestrator.py create mode 100644 backend/app/services/storage.py create mode 100644 backend/app/services/workflow_binder.py create mode 100644 backend/requirements.txt create mode 100644 backend/run.py create mode 100644 backend/storage/.gitkeep create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app/dashboard/error.tsx create mode 100644 frontend/src/app/dashboard/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/jobs/[id]/error.tsx create mode 100644 frontend/src/app/jobs/[id]/page.tsx create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/components/auth-form.tsx create mode 100644 frontend/src/components/dashboard-client.tsx create mode 100644 frontend/src/components/video-player.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/time.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 infra/animatrix-backend.service create mode 100644 infra/animatrix-frontend.service create mode 100644 infra/animatrix.desineuron.in.nginx.conf create mode 100644 scripts/build_frontend_standalone.sh create mode 100644 workflows/animate/wan22_animate_mix_v1.json create mode 100644 workflows/animate/wan22_animate_move_v1.json create mode 100644 workflows/s2v/wan22_s2v_v1.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e189a23 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Animatrix Project Truth.md b/Animatrix Project Truth.md new file mode 100644 index 0000000..939e583 --- /dev/null +++ b/Animatrix Project Truth.md @@ -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 diff --git a/Docs/Animatrix Build Prompt for Coding Agent - First Principles.md b/Docs/Animatrix Build Prompt for Coding Agent - First Principles.md new file mode 100644 index 0000000..d80dc60 --- /dev/null +++ b/Docs/Animatrix Build Prompt for Coding Agent - First Principles.md @@ -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.` diff --git a/Docs/Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md b/Docs/Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md new file mode 100644 index 0000000..394ad0e --- /dev/null +++ b/Docs/Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md @@ -0,0 +1,1237 @@ +# Animatrix Monolithic SRS - Wan 2.2 Flow Studio + +Date: 2026-04-15 + +Authoring context: This document defines the first production-ready Animatrix system built on top of the existing Desineuron ingress, the current ComfyUI GPU service, and the Wan 2.2 model family. + +## 1. Purpose + +Animatrix is a focused product for guided character video generation. It is not a general-purpose node editor. It is a constrained, operator-safe application that exposes two production workflows behind one simple frontend: + +1. Character Animation and Replacement using `Wan2.2-Animate-14B` +2. Audio-Driven Character Performance using `Wan2.2-S2V-14B` + +The frontend interaction model is inspired by the simplicity and compositional feel of Google Flow, but the execution runtime is ComfyUI-backed and Desineuron-hosted. + +The objective is to give users a minimal interface: + +- prompt box +- ground-truth starting image upload +- optional reference images and pose sheet uploads +- optional audio upload +- simple mode selection +- one-click generation + +while the backend handles: + +- asset ingestion +- workflow selection +- parameter validation +- ComfyUI prompt orchestration +- queueing +- status tracking +- result persistence +- streaming-ready delivery + +## 2. Executive Product Truth + +Animatrix v1 must be built around the actual Wan 2.2 model split, not a blended assumption. + +Capability mapping: + +- `Wan2.2-Animate-14B` is for character animation and character replacement. +- `Wan2.2-S2V-14B` is for audio-driven video generation with dialogue, singing, and performance. +- `Wan2.2 Fun Inp` is the Wan family workflow for strict first-frame and last-frame control. +- `Wan2.2 Fun Control` is the Wan family workflow for stronger control-video inputs such as OpenPose, depth, canny, and trajectory control. + +Therefore the first release must not falsely claim that one single model covers all of the following natively: + +- character replacement +- motion transfer +- audio lip-sync +- exact first/last-frame constraints + +It does not. + +The correct v1 product line is: + +- Workflow A: `Animate Studio` on `Wan2.2-Animate-14B` +- Workflow B: `Audio Performance Studio` on `Wan2.2-S2V-14B` + +The correct v1.1 or v2 expansion is: + +- Workflow C: `Start/End Frame Studio` on `Wan2.2 Fun Inp` +- Workflow D: `Pose/Trajectory Control Studio` on `Wan2.2 Fun Control` + +This distinction is mandatory because it affects UI truthfulness, node graphs, validation rules, asset requirements, and customer expectations. + +## 3. Source Truth and Rationale + +This SRS is grounded in the following current sources: + +- official Wan 2.2 GitHub repository: `https://github.com/Wan-Video/Wan2.2` +- official Wan 2.2 Animate model page: `https://huggingface.co/Wan-AI/Wan2.2-Animate-14B` +- official ComfyUI Wan 2.2 docs: + - `https://docs.comfy.org/tutorials/video/wan/wan2_2` + - `https://docs.comfy.org/tutorials/video/wan/wan2-2-animate` + - `https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v` + - `https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp` + - `https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control` +- current Desineuron infrastructure truth: + - [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) + +Critical source-backed facts that drive the design: + +- ComfyUI is already exposed safely through `https://comfy.desineuron.in` +- the GPU service already runs behind stable ingress +- `Wan2.2-Animate-14B` supports two operating modes in ComfyUI docs: `Mix` and `Move` +- `Wan2.2-S2V-14B` is the audio-driven workflow with image plus audio inputs +- ComfyUI’s official Animate docs require additional custom nodes for the full direct workflow +- exact start/end-frame control is documented under `Wan2.2 Fun Inp`, not Animate + +## 4. Product Vision + +Animatrix should behave like a focused video creation surface, not like a research sandbox. + +The product promise is: + +"Upload a hero frame, optionally attach references, pose guidance, or audio, write a prompt, and generate a directed character video without touching ComfyUI nodes." + +The UI must feel lightweight, but the execution system behind it must be opinionated and rigid enough to be supportable. + +That means: + +- limited number of modes +- strict validation +- controlled presets +- reproducible workflow JSON +- consistent output formats +- no raw-node exposure in the customer-facing frontend + +## 5. Scope + +### 5.1 In Scope for v1 + +- one frontend +- one backend API +- two ComfyUI production workflows +- status and result tracking +- stable ingress compatibility +- persistent storage for uploads and outputs +- preview and download experience +- operator-oriented logging and troubleshooting +- support for team usage through the existing Desineuron architecture + +### 5.2 Out of Scope for v1 + +- arbitrary node editing by end users +- live collaborative editing +- in-browser timeline editing +- multi-scene stitching +- automatic sound effects design +- full NLE replacement +- customer-facing batch farms +- fine-tuning or LoRA training + +### 5.3 Deferred but Planned + +- first/last-frame exact control as a third workflow using `Wan2.2 Fun Inp` +- stronger pose or trajectory control using `Wan2.2 Fun Control` +- style packs and prompt presets +- branded credits or quota system +- user libraries and reusable character packs + +## 6. User Personas + +### 6.1 Internal Creative Operator + +This user understands creative direction but should not need to edit node graphs. They need: + +- fast iteration +- predictable inputs +- reliable outputs +- access to previous runs + +### 6.2 Sales Demo Operator + +This user needs a polished experience that can be shown live. They need: + +- simple UX +- low operator error +- dependable queue feedback +- visible result cards + +### 6.3 Technical Media Designer + +This user understands reference material quality and wants more control without dropping into raw ComfyUI. They need: + +- reference images +- pose sheet upload +- clear mode distinctions +- optional advanced settings + +## 7. Functional Overview + +Animatrix v1 will contain one shell product with two generation modes. + +### 7.1 Mode A: Animate Studio + +Underlying engine: + +- `Wan2.2-Animate-14B` + +Primary purpose: + +- animate a character from a source image using the motion and expression from a source video +- replace the subject in a video with a new character image + +Sub-modes: + +- `Move` +- `Mix` + +User inputs: + +- prompt +- ground-truth character image, required +- source motion video, required +- optional reference images +- optional pose sheet image set +- optional aspect preset +- optional duration target + +### 7.2 Mode B: Audio Performance Studio + +Underlying engine: + +- `Wan2.2-S2V-14B` + +Primary purpose: + +- generate a character video from a static image and audio input +- support dialogue, singing, and audio-driven performance + +User inputs: + +- prompt +- ground-truth character image, required +- source audio, required +- optional reference images +- optional pose sheet image set +- optional full-body / half-body framing preset +- optional duration target inferred from audio length + +## 8. Frontend Vision + +The frontend must preserve the interaction language shown in the reference screenshots: + +- one large prompt composer +- image chips at the top-left of the composer +- plus button for additional attachments +- compact right-aligned mode selector +- advanced settings revealed through a controlled panel, not always visible + +The frontend should feel immediate, not enterprise-heavy. + +### 8.1 Core Layout + +Top-level zones: + +1. Attachment rail +2. Prompt composer +3. Optional advanced drawer +4. Generate action and mode switch +5. Run history / output gallery below + +### 8.2 Attachment Types + +Attachment chips in v1: + +- `Ground Truth` +- `Reference` +- `Pose Sheet` +- `Audio` +- `Motion Video` + +Visibility rules: + +- `Ground Truth` always available +- `Motion Video` visible only in Animate Studio +- `Audio` visible only in Audio Performance Studio +- `Pose Sheet` optional in both modes +- `Reference` optional in both modes + +### 8.3 Frontend Controls + +Base controls: + +- prompt text area +- optional keyword helper line +- mode toggle: `Animate` / `Audio` +- output aspect toggle: `9:16`, `16:9`, later `1:1` +- quality profile: `Draft`, `Standard`, `High` +- generate button + +Advanced controls: + +- Animate sub-mode: `Move` / `Mix` +- target duration +- seed +- negative prompt +- extension segments +- background preservation flag +- relighting flag +- lip-sync intensity or audio adherence preset + +### 8.4 UX Rules + +- do not expose raw model names to standard users +- use user language like `Animate`, `Replace Character`, `Audio Performance` +- surface warnings before submission if required inputs are missing +- show asset previews as compact rounded chips +- keep advanced panel collapsed by default + +## 9. Exact Capability Mapping by Workflow + +### 9.1 Workflow A: Animate Studio + +Supported in v1: + +- character animation from image plus motion video +- character replacement from image plus source video +- prompt conditioning +- optional pose preprocessing +- iterative video extension + +Not truly supported by Animate Studio itself: + +- direct audio-driven lip sync +- strict start/end-frame guarantees + +### 9.2 Workflow B: Audio Performance Studio + +Supported in v1: + +- image plus audio driven generation +- prompt-conditioned motion/environment +- dialogue and singing style use cases +- long-form generation by extension chunks + +Not truly supported by S2V itself: + +- guaranteed subject replacement from an existing motion video +- exact last-frame lock + +### 9.3 Pose Sheet Truth + +The user-requested pose sheet can be supported in two ways: + +1. Soft support in v1 + - pose sheet stored as reference asset + - backend uses it for prompt augmentation and optional preprocessing assistance + - operator can map selected sheet frames to manual key pose hints + +2. Hard support in later release + - migrate pose guidance to a dedicated `Wan2.2 Fun Control` or equivalent control-video workflow + +The v1 document must state this honestly. A static pose sheet is not the same as a control video. It helps guide generation but does not become full deterministic motion control without an additional preprocessing and control pipeline. + +## 10. Ground Truth Asset Model + +The user’s "ground truth" image is the canonical identity anchor. + +In both workflows it must serve as: + +- the primary subject identity reference +- the default starting visual state +- the basis for preview thumbnails + +Rules: + +- exactly one primary ground-truth image per run +- image must pass minimum size and aspect checks +- background should preferably be clean but not mandatory +- user may crop or center the character before submission + +Optional extension: + +- future support for multiple identity references per character pack + +## 11. Workflow Architecture + +### 11.1 System Shape + +```text +Browser + -> Animatrix frontend + -> Animatrix API + -> job store + -> asset store + -> workflow composer + -> ComfyUI client + -> https://comfy.desineuron.in + -> GPU ComfyUI service + -> Wan2.2 workflow execution + -> result collector + -> output persistence + -> result CDN / static delivery +``` + +### 11.2 Architectural Rule + +The frontend must never submit raw prompts directly to ComfyUI. + +The backend must always mediate: + +- asset upload +- workflow selection +- workflow JSON parameter binding +- run metadata persistence +- output tracking + +This is required for observability, rate control, product safety, and sales-readiness. + +## 12. Ingress and Deployment Compatibility + +Animatrix must be designed around the current Desineuron ingress truth. + +Current infrastructure constraints: + +- ComfyUI is already live at `https://comfy.desineuron.in` +- ComfyUI runs behind AWS ingress and stable TLS +- GPU private IP is not a stable application contract +- Linux origin is currently `192.168.1.2` + +### 12.1 Mandatory Integration Rule + +Animatrix backend must integrate with ComfyUI through the stable hostname or through a controlled internal service abstraction that resolves to the same managed route. + +Do not bind Animatrix to: + +- the GPU public IP +- direct `8188` public traffic +- hardcoded current private IP + +### 12.2 Recommended Host Layout + +Recommended public routing: + +- `animatrix.desineuron.in` -> frontend and public product shell +- `api.animatrix.desineuron.in` or `animatrix.desineuron.in/api` -> backend API +- `comfy.desineuron.in` -> internal execution dependency only, not user-facing + +If separate subdomains are not created immediately, the fallback deployment pattern may mirror the current Velocity site pattern: + +- frontend served from Linux origin through ingress +- backend served from Linux origin through ingress +- backend calls ComfyUI through `https://comfy.desineuron.in` + +## 13. Runtime Components + +### 13.1 Frontend Application + +Responsibilities: + +- render simplified generation interface +- manage uploads +- validate user fields before submit +- create job requests +- poll or subscribe to job progress +- render previews and outputs + +Suggested stack: + +- Next.js or Vite React app +- Tailwind or CSS modules +- upload components with image/audio/video preview + +### 13.2 Animatrix Backend API + +Responsibilities: + +- receive upload metadata +- store files +- generate canonical run record +- choose workflow template +- bind node inputs +- submit prompt payload to ComfyUI +- track prompt ID and history +- collect generated outputs +- persist result artifacts + +Suggested stack: + +- FastAPI if aligned with existing Python-heavy operations +- or Node/TypeScript only if the team wants one frontend-backend language + +Recommendation: + +- use Python FastAPI for v1 if reusing current Desineuron operational style and image/media tooling + +### 13.3 Workflow Composer + +Responsibilities: + +- keep frozen template JSON files in version control +- inject prompt text, model selections, size, length, and asset paths +- enforce mode-specific constraints + +This component must be deterministic. It is not a prompt improviser. + +### 13.4 ComfyUI Execution Layer + +Responsibilities: + +- execute pre-approved workflow JSON +- expose queue, prompt, history, upload endpoints +- return output metadata + +### 13.5 Asset Store + +Responsibilities: + +- raw upload persistence +- normalized derivative generation +- final output video persistence +- preview image generation + +Recommended storage split: + +- hot local cache on Linux origin +- durable object storage in S3 for long-term retention + +## 13A. Current Infrastructure Contract + +Animatrix v1 must be compatible with the currently operating Desineuron media stack as it exists today. + +Live execution truth: + +- public ComfyUI hostname: `https://comfy.desineuron.in` +- ingress elastic IP: `98.87.120.120` +- GPU private target currently managed behind ingress +- Linux origin currently: `192.168.1.2` + +Current GPU-side storage truth: + +- ComfyUI app root: `/opt/dlami/nvme/ComfyUI` +- HF cache: `/opt/dlami/nvme/hf` +- model staging root: `/opt/dlami/nvme/model-staging` +- model logs: `/opt/dlami/nvme/model-logs` + +Current model hydration truth: + +- durable bucket family already in use: `s3://project-velocity/models/` +- existing Wan hydration prefix: `s3://project-velocity/models/Wan2.2-Animate-14B/` + +Animatrix must not introduce a second contradictory deployment path for ComfyUI. It must reuse this stable route and storage discipline. + +## 13B. ComfyUI API Contract + +The backend integration layer must be implemented against the current ComfyUI HTTP contract. + +Required endpoints: + +- `GET /` +- `POST /prompt` +- `GET /history/{prompt_id}` +- `GET /queue` +- `POST /upload/image` + +Recommended extension checks: + +- health probe against `/` +- prompt submission response validation +- history polling with bounded backoff +- queue introspection for operator dashboards + +The backend must wrap these endpoints in a typed client and must not scatter raw HTTP calls throughout business logic. + +## 13C. Model and Node Manifest + +### Workflow A: Animate Studio Required Assets + +Required model family: + +- `Wan2.2-Animate-14B` +- `clip_vision_h.safetensors` +- `wan_2.1_vae.safetensors` +- `umt5_xxl_fp8_e4m3fn_scaled.safetensors` + +Required custom nodes: + +- `ComfyUI-KJNodes` +- `ComfyUI-comfyui_controlnet_aux` + +Suggested placement contract: + +- diffusion model files under `ComfyUI/models/diffusion_models/` +- text encoder under `ComfyUI/models/text_encoders/` +- VAE under `ComfyUI/models/vae/` +- CLIP Vision under `ComfyUI/models/clip_vision/` + +### Workflow B: Audio Performance Studio Required Assets + +Required model family: + +- `wan2.2_s2v_14B_fp8_scaled.safetensors` or `wan2.2_s2v_14B_bf16.safetensors` +- `wav2vec2_large_english_fp16.safetensors` +- `wan_2.1_vae.safetensors` +- `umt5_xxl_fp8_e4m3fn_scaled.safetensors` + +Suggested placement contract: + +- diffusion model under `ComfyUI/models/diffusion_models/` +- text encoder under `ComfyUI/models/text_encoders/` +- audio encoder under `ComfyUI/models/audio_encoders/` +- VAE under `ComfyUI/models/vae/` + +### Deferred Workflow Assets + +For future strict start/end-frame control: + +- `Wan2.2 Fun Inp` models and optional associated LoRAs + +For future stronger pose control: + +- `Wan2.2 Fun Control` + +The frontend and API must be written so these workflows can be added later without reworking the entire product shell. + +## 14. File and Repository Blueprint + +Animatrix should be structured as an application repository or top-level product directory with explicit separation between app, API, and workflow assets. + +Recommended layout: + +```text +Animatrix/ + docs/ + Animatrix Monolithic SRS - Wan 2.2 Flow Studio.md + frontend/ + src/ + app/ + components/ + features/ + lib/ + styles/ + backend/ + app/ + api/ + services/ + models/ + repositories/ + workers/ + workflows/ + animate/ + wan22_animate_mix.json + wan22_animate_move.json + s2v/ + wan22_s2v_base.json + shared/ + prompt_profiles/ + node_maps/ + scripts/ + deploy/ + media/ + sync/ + infra/ + systemd/ + nginx/ + caddy/ + tests/ + api/ + workflows/ + ui/ +``` + +## 15. Workflow A Detailed Design: Animate Studio + +### 15.1 Objective + +Deliver a workflow that supports: + +- character replacement from a source video +- character animation from a performer video +- prompt-guided visual refinement + +### 15.2 Input Contract + +Required: + +- `prompt` +- `ground_truth_image` +- `motion_video` +- `mode`: `move` or `mix` + +Optional: + +- `reference_images[]` +- `pose_sheet_images[]` +- `negative_prompt` +- `duration_override_seconds` +- `aspect_ratio` +- `quality_profile` +- `seed` + +### 15.3 Output Contract + +Primary outputs: + +- `video_mp4` +- `poster_frame_jpg` +- `job_manifest.json` +- `debug_metadata.json` + +Secondary outputs: + +- pose preview if preprocessing is enabled +- first-frame snapshot + +### 15.4 Internal Workflow Stages + +1. Ingest image and video +2. Normalize formats and dimensions +3. Extract first frame and thumbnail +4. Run optional DWPose or auxiliary preprocessing +5. Bind workflow JSON for `move` or `mix` +6. Upload normalized assets to ComfyUI +7. Submit workflow +8. Poll queue and history +9. Collect result paths +10. Persist final outputs and metadata + +### 15.5 ComfyUI Notes + +The official Animate workflow requires: + +- `clip_vision_h.safetensors` +- `wan_2.1_vae.safetensors` +- `umt5_xxl_fp8_e4m3fn_scaled.safetensors` +- Animate diffusion model +- optional Lightning LoRA +- custom nodes: + - `ComfyUI-KJNodes` + - `ComfyUI-comfyui_controlnet_aux` + +### 15.6 Product-Level Rule + +Animatrix v1 must hide these internals from the standard UI, but the backend and operator docs must track them exactly. + +## 16. Workflow B Detailed Design: Audio Performance Studio + +### 16.1 Objective + +Deliver a workflow that supports: + +- talking-head and half-body performance +- singing and dialogue use cases +- audio-driven facial and motion synthesis + +### 16.2 Input Contract + +Required: + +- `prompt` +- `ground_truth_image` +- `audio_file` + +Optional: + +- `reference_images[]` +- `pose_sheet_images[]` +- `negative_prompt` +- `framing_mode`: `portrait`, `half_body`, `full_body` +- `quality_profile` +- `seed` + +### 16.3 Output Contract + +Primary outputs: + +- `video_mp4` +- `poster_frame_jpg` +- `job_manifest.json` +- `debug_metadata.json` + +### 16.4 Internal Workflow Stages + +1. Ingest image and audio +2. Normalize sample rate and file format +3. Infer required frame count from audio duration +4. Determine required S2V extension chunks +5. Bind workflow JSON +6. Upload image and audio to ComfyUI +7. Submit workflow +8. Poll queue and history +9. Collect output video +10. Persist artifacts + +### 16.5 ComfyUI Notes + +The official S2V workflow requires: + +- `wan2.2_s2v_14B_fp8_scaled.safetensors` or bf16 variant +- `wav2vec2_large_english_fp16.safetensors` +- `wan_2.1_vae.safetensors` +- `umt5_xxl_fp8_e4m3fn_scaled.safetensors` + +The ComfyUI docs note that: + +- fp8 uses less VRAM +- bf16 may reduce quality degradation +- Lightning LoRA can reduce generation time but can also significantly reduce quality and dynamics + +Therefore Animatrix must default to: + +- `Standard`: fp8 without aggressive LoRA by default for customer-facing quality stability +- `Draft`: fp8 with acceleration options +- `High`: bf16 where hardware allows + +## 17. UI-to-Workflow Mapping + +The UI must map cleanly to backend request objects. + +### 17.1 Shared Fields + +- `mode` +- `prompt` +- `negative_prompt` +- `ground_truth_asset_id` +- `reference_asset_ids[]` +- `pose_sheet_asset_ids[]` +- `aspect_ratio` +- `quality_profile` +- `seed` + +### 17.2 Animate-Specific Fields + +- `motion_video_asset_id` +- `animate_submode` +- `background_preservation` +- `relighting` +- `extension_segments` + +### 17.3 Audio-Specific Fields + +- `audio_asset_id` +- `framing_mode` +- `audio_adherence_profile` +- `extension_segments` + +## 18. Suggested Backend API + +### 18.1 Asset Endpoints + +- `POST /api/assets/image` +- `POST /api/assets/video` +- `POST /api/assets/audio` +- `GET /api/assets/{asset_id}` + +### 18.2 Job Endpoints + +- `POST /api/jobs/animate` +- `POST /api/jobs/audio-performance` +- `GET /api/jobs/{job_id}` +- `GET /api/jobs/{job_id}/events` +- `GET /api/jobs/{job_id}/outputs` +- `POST /api/jobs/{job_id}/cancel` + +### 18.3 Admin Endpoints + +- `GET /api/admin/workflows` +- `GET /api/admin/health` +- `GET /api/admin/queue` +- `POST /api/admin/retry/{job_id}` + +### 18.4 Websocket or SSE Progress Channel + +Recommended: + +- `GET /api/jobs/{job_id}/stream` + +This should emit: + +- accepted +- uploaded +- queued +- executing +- collecting_outputs +- completed +- failed + +The frontend should use this channel if available and fall back to polling if the connection drops. + +## 19. Data Model + +### 19.1 Asset + +Fields: + +- `asset_id` +- `asset_type` +- `mime_type` +- `original_filename` +- `storage_url` +- `thumbnail_url` +- `width` +- `height` +- `duration_seconds` +- `size_bytes` +- `created_at` + +### 19.2 Job + +Fields: + +- `job_id` +- `mode` +- `workflow_template` +- `status` +- `submitted_by` +- `prompt` +- `negative_prompt` +- `settings_json` +- `comfy_prompt_id` +- `created_at` +- `updated_at` + +### 19.3 JobOutput + +Fields: + +- `output_id` +- `job_id` +- `video_url` +- `poster_url` +- `manifest_url` +- `duration_seconds` +- `resolution` +- `fps` +- `created_at` + +## 20. Workflow Template Governance + +Workflow JSON must be treated as versioned product assets. + +Rules: + +- each production workflow JSON must have an immutable version identifier +- node IDs must be mapped in a dedicated config file +- backend parameter injection must never depend on informal manual node lookup +- each workflow change must pass snapshot regression checks + +Required metadata for every workflow: + +- `workflow_name` +- `workflow_version` +- `model_family` +- `required_assets` +- `required_models` +- `custom_nodes` +- `compatible_backend_version` + +## 21. Storage and Delivery Design + +### 21.1 Inputs + +Store raw uploads in durable storage with stable references. + +Recommended: + +- object storage in S3 +- local temporary cache for preprocessing + +### 21.2 Outputs + +Store: + +- mp4 output +- poster image +- optional animated preview +- manifest json + +### 21.3 Delivery + +Outputs must be streamable from a public HTTPS origin via ingress. + +If using Linux origin: + +- serve final assets through nginx under Animatrix public domain + +If using S3-backed storage: + +- use signed or public-read delivery depending on account mode + +## 22. Quality Profiles + +Animatrix must expose productized quality profiles rather than raw step counts to users. + +### 22.1 Draft + +Purpose: + +- internal ideation +- faster previews + +Behavior: + +- lower resolution +- lower steps +- acceleration LoRA allowed + +### 22.2 Standard + +Purpose: + +- most normal production runs + +Behavior: + +- balanced speed and quality +- conservative defaults +- no quality-destructive shortcuts unless explicitly enabled + +### 22.3 High + +Purpose: + +- demo and delivery quality + +Behavior: + +- higher quality model variant when available +- larger resolution +- longer runtime accepted + +## 23. Error Handling + +Failure classes: + +- missing asset +- invalid asset format +- unsupported aspect ratio +- workflow binding failure +- ComfyUI upload failure +- ComfyUI queue failure +- generation timeout +- result collection failure + +User-facing errors must be simplified. + +Operator-facing logs must preserve exact failure cause. + +## 23A. Validation Rules + +### Shared Validation + +- reject empty prompt if prompt is required by the selected workflow profile +- reject missing ground-truth image +- reject unsupported file extensions +- reject files above configured upload limit + +### Animate Studio Validation + +- reject missing motion video +- reject unsupported source video codecs that cannot be normalized +- reject conflicting `move` and `mix` settings + +### Audio Performance Studio Validation + +- reject missing audio +- reject audio longer than configured maximum duration for the selected profile +- normalize sample rate before workflow submission + +### Pose Sheet Validation + +- accept only supported image formats +- cap pose sheet image count in v1 +- mark pose sheet as "soft guidance" in job metadata unless a later hard-control pipeline is introduced + +## 24. Observability + +Minimum operational telemetry: + +- job creation rate +- queue depth +- mean wait time +- mean generation time by workflow +- failure rate by workflow version +- storage growth +- top asset sizes + +Required correlation identifiers: + +- `job_id` +- `asset_id` +- `comfy_prompt_id` + +## 25. Security and Access Control + +Rules: + +- do not expose raw ComfyUI publicly to end users as the product surface +- backend owns ComfyUI credentials and workflow orchestration +- validate file size and MIME type on upload +- strip executable uploads +- limit accepted formats +- preserve audit trail for every run + +## 26. Team and Operator UX + +The system must support: + +- internal team usage through the stable ingress +- supportable operator triage +- easy workflow version rollback +- safe demo usage during sales calls + +Operators need: + +- admin queue view +- job replay +- access to input and output manifests +- workflow version annotation + +## 27. Non-Functional Requirements + +### 27.1 Reliability + +- no direct dependency on ephemeral GPU public IP +- graceful retry around ComfyUI upload and history polling +- job state persisted outside memory + +### 27.2 Performance + +- fast upload validation +- async polling and result collection +- cached thumbnails + +### 27.3 Scalability + +- workflow templates stateless +- API horizontally scalable +- storage externalized + +### 27.4 Maintainability + +- one source-of-truth workflow config per mode +- explicit model manifest +- no hidden hand-edited production JSON + +### 27.5 Sales Readiness + +- stable hostname +- reliable queue messaging +- polished success and failure states +- deterministic demo inputs + +## 27A. Demo and Commercial Readiness Requirements + +Animatrix will be used in live demos and pre-sales conversations. That changes the bar. + +Required product behavior: + +- first meaningful UI paint fast enough for live sales use +- one-click sample project loading for demo mode +- clear progress messaging during long generations +- shareable output URL or operator download path +- no raw ComfyUI terminology in the customer-facing layer unless explicitly in admin mode + +Required operator support behavior: + +- known-good demo assets packaged and versioned +- visible warning when GPU queue is saturated +- ability to retry a failed job without recreating all metadata manually + +## 28. MVP Acceptance Criteria + +Animatrix v1 is only considered complete when all of the following are true: + +1. A user can upload a ground-truth image, type a prompt, attach a motion video, select `Move` or `Mix`, and receive a finished video output. +2. A user can upload a ground-truth image, type a prompt, attach audio, and receive an audio-driven character video. +3. Both flows work through the stable Desineuron ingress model and do not depend on hardcoded GPU IPs. +4. Every run produces a persisted job record and output manifest. +5. Generated videos are streamable over HTTPS. +6. Operators can inspect job state and correlate product job ID to ComfyUI prompt ID. +7. The UI remains simple enough for a non-technical demo operator. + +## 29. Explicit Product Decisions + +### 29.1 What v1 Must Say No To + +Animatrix v1 must not claim: + +- perfect deterministic pose-sheet control +- exact first and last frame locking +- full timeline editing +- full audio mastering + +### 29.2 What v1 Must Say Yes To + +Animatrix v1 can truthfully claim: + +- guided character animation +- guided character replacement +- audio-driven talking or performance video +- reference-assisted generation +- production-safe simplified UI on top of ComfyUI + +## 30. Recommended Delivery Phases + +### Phase 1 + +- backend skeleton +- asset model +- one frozen Animate workflow +- one frozen S2V workflow +- barebones frontend + +### Phase 2 + +- quality profiles +- operator dashboard +- output gallery +- S3 persistence + +### Phase 3 + +- first/last-frame workflow +- stronger pose control +- reusable character libraries + +## 31. Final Architecture Recommendation + +Build Animatrix as a thin product layer over stable infrastructure that already exists: + +- keep ComfyUI where it is +- keep ingress where it is +- add a dedicated Animatrix backend +- keep the frontend intentionally minimal +- treat workflow JSON as versioned software artifacts + +Do not begin by building a large generic creative suite. + +Build the narrowest saleable product first: + +- `Animate Studio` +- `Audio Performance Studio` + +Then expand to: + +- `Start/End Frame Studio` +- `Pose Control Studio` + +## 32. Bottom Line + +Animatrix v1 should be a Flow-like creative surface backed by two real Wan 2.2 workflows, not one imaginary super-workflow. + +The correct implementation target is: + +- one frontend +- one orchestration backend +- two workflow families +- one stable ingress-compatible execution path +- one durable output system + +If the team follows this document strictly, the result will be productizable, supportable, and compatible with the current Desineuron infrastructure without lying about model capabilities. diff --git a/Docs/RUNBOOK.md b/Docs/RUNBOOK.md new file mode 100644 index 0000000..a539dfc --- /dev/null +++ b/Docs/RUNBOOK.md @@ -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/.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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14d525c --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..84d9438 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py new file mode 100644 index 0000000..5d8bfe7 --- /dev/null +++ b/backend/app/api/routes/admin.py @@ -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} diff --git a/backend/app/api/routes/assets.py b/backend/app/api/routes/assets.py new file mode 100644 index 0000000..d9c5205 --- /dev/null +++ b/backend/app/api/routes/assets.py @@ -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(), + } diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..6d70156 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -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 diff --git a/backend/app/api/routes/jobs.py b/backend/app/api/routes/jobs.py new file mode 100644 index 0000000..9f44e2d --- /dev/null +++ b/backend/app/api/routes/jobs.py @@ -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)) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..1dc26b9 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..d3a7b7f --- /dev/null +++ b/backend/app/core/deps.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d1e6d9e --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 0000000..5a575f2 --- /dev/null +++ b/backend/app/db/init_db.py @@ -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}, + ) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..8759e1c --- /dev/null +++ b/backend/app/db/session.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..55185a5 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8e4012e --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.models import Asset, Job, JobEvent, JobOutput, User + +__all__ = ["User", "Asset", "Job", "JobOutput", "JobEvent"] diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..4e600b1 --- /dev/null +++ b/backend/app/models/models.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..b7507c7 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..6f55d1e --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -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 diff --git a/backend/app/services/comfy_client.py b/backend/app/services/comfy_client.py new file mode 100644 index 0000000..71a7c54 --- /dev/null +++ b/backend/app/services/comfy_client.py @@ -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() diff --git a/backend/app/services/orchestrator.py b/backend/app/services/orchestrator.py new file mode 100644 index 0000000..cd49911 --- /dev/null +++ b/backend/app/services/orchestrator.py @@ -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() diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..0acd03f --- /dev/null +++ b/backend/app/services/storage.py @@ -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) diff --git a/backend/app/services/workflow_binder.py b/backend/app/services/workflow_binder.py new file mode 100644 index 0000000..deed556 --- /dev/null +++ b/backend/app/services/workflow_binder.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3d1386c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..5ecd9f2 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,5 @@ +import uvicorn + + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/storage/.gitkeep b/backend/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..c10e07d --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e1903e3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2167 @@ +{ + "name": "animatrix-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "animatrix-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.9", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0c54238 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/src/app/dashboard/error.tsx b/frontend/src/app/dashboard/error.tsx new file mode 100644 index 0000000..6ea7180 --- /dev/null +++ b/frontend/src/app/dashboard/error.tsx @@ -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 ( +
+
+

Dashboard

+

Dashboard Runtime Error

+

+ The dashboard hit a client-side render error. This route now fails closed with a local fallback instead of collapsing the entire application shell. +

+ +
+ {error.message || "Unknown dashboard error"} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..ffc80f8 --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { DashboardClient } from "@/components/dashboard-client"; + +export default function DashboardPage() { + return ; +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..bf22f28 --- /dev/null +++ b/frontend/src/app/globals.css @@ -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); +} diff --git a/frontend/src/app/jobs/[id]/error.tsx b/frontend/src/app/jobs/[id]/error.tsx new file mode 100644 index 0000000..6b5358a --- /dev/null +++ b/frontend/src/app/jobs/[id]/error.tsx @@ -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 ( +
+
+
+
+

Generation Detail

+

Client Runtime Error

+
+ +
+ +

+ The detail overlay hit a client-side error. The route now has a guarded fallback instead of breaking the whole app shell. +

+ +
+ {error.message || "Unknown client-side exception"} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/jobs/[id]/page.tsx b/frontend/src/app/jobs/[id]/page.tsx new file mode 100644 index 0000000..51c2a6c --- /dev/null +++ b/frontend/src/app/jobs/[id]/page.tsx @@ -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(null); + const [error, setError] = useState(""); + + const load = async () => { + try { + const data = await apiGet(`/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 ( +
+
+
+
+
+

Generation Detail

+

Job {params.id}

+

+ Review the generated output, runtime events, and download the final render from this overlay without leaving the dashboard flow. +

+
+ +
+ + +
+
+ + {error ?
{error}
: null} + + {!job ? ( +
Loading generation detail...
+ ) : ( +
+
+
+
+
+ +
{job.workflow_template_name ?? "workflow pending"}
+
+ {videoOutput ? ( + + + Download + + ) : null} +
+ + {videoOutput?.output_type === "video" ? ( + + ) : ( +
+ No output video yet. +
+ )} +
+ +
+
+
Prompt
+

{job.prompt}

+ {job.negative_prompt ? ( +
+
Negative Prompt
+
{job.negative_prompt}
+
+ ) : null} + {job.error_message ? ( +
+ {job.error_message} +
+ ) : null} +
+ +
+
Output Files
+
+ {outputs.length === 0 ?
No outputs persisted yet.
: null} + {outputs.map((output) => ( +
+
+
+
{output.output_type}
+
{formatIstDateTime(output.created_at)}
+
+ + + Download + +
+
+ ))} +
+
+
+
+ +
+
+
Runtime Events
+
{events.length} event{events.length === 1 ? "" : "s"}
+
+
+ {events.map((event) => ( +
+
+
{event.event_type}
+
{formatIstDateTime(event.created_at)}
+
+
{event.message}
+
+ ))} +
+ {events.length === 0 ?
No runtime events available yet.
: null} +
+
+ )} +
+
+
+ ); +} + +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
{status}
; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..36909da --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..18117f4 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,5 @@ +import { AuthForm } from "@/components/auth-form"; + +export default function LoginPage() { + return ; +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..9ef1235 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/dashboard"); +} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..206f3d6 --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -0,0 +1,5 @@ +import { AuthForm } from "@/components/auth-form"; + +export default function RegisterPage() { + return ; +} diff --git a/frontend/src/components/auth-form.tsx b/frontend/src/components/auth-form.tsx new file mode 100644 index 0000000..d04c625 --- /dev/null +++ b/frontend/src/components/auth-form.tsx @@ -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 ( +
+
+
+

Animatrix

+

+ {mode === "login" ? "Sign in" : "Create account"} +

+

+ {mode === "login" + ? "Use your workspace account to open the generator." + : "Create a local account for this workspace."} +

+
+ +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+ {mode === "login" ? ( + <> + No account yet? Register + + ) : ( + <> + Already registered? Sign in + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard-client.tsx b/frontend/src/components/dashboard-client.tsx new file mode 100644 index 0000000..1ada4ce --- /dev/null +++ b/frontend/src/components/dashboard-client.tsx @@ -0,0 +1,1161 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + ArrowLeftRight, + CheckSquare, + Ellipsis, + ChevronDown, + FileVideo, + ImageIcon, + Mic2, + PanelTopOpen, + Plus, + RefreshCw, + Settings2, + Trash2, + Upload, + UserCircle2, + Video, + X, +} from "lucide-react"; + +import { apiGet, apiPost, apiUrl } from "@/lib/api"; +import { formatIstDateTime } from "@/lib/time"; +import type { AdminHealth, Asset, Job, JobsSummary, User } from "@/lib/types"; + +type ComposerState = { + mode: "animate" | "audio"; + submode: "move" | "mix"; + activeSurface: "frames" | "ingredients"; + frameEndpoint: "start" | "end"; + prompt: string; + negativePrompt: string; + groundTruthId: string; + motionId: string; + audioId: string; + poseId: string; + referenceIds: string[]; + aspectPreset: "16:9" | "1:1" | "9:16"; + durationPreset: "5s" | "8s"; + generationCount: 1 | 2 | 3 | 4; + modelPreset: "wan22-a14b"; +}; + +type AssetKind = "image" | "video" | "audio" | "pose_sheet"; + +type PickerState = + | { + title: string; + kinds: AssetKind[]; + target: "groundTruth" | "motion" | "audio" | "pose" | "reference"; + } + | null; + +const initialState: ComposerState = { + mode: "animate", + submode: "move", + activeSurface: "ingredients", + frameEndpoint: "start", + prompt: "", + negativePrompt: "", + groundTruthId: "", + motionId: "", + audioId: "", + poseId: "", + referenceIds: [], + aspectPreset: "16:9", + durationPreset: "5s", + generationCount: 1, + modelPreset: "wan22-a14b", +}; + +const ASPECT_PRESETS: Record = { + "16:9": { label: "16:9", width: 832, height: 468 }, + "1:1": { label: "1:1", width: 640, height: 640 }, + "9:16": { label: "9:16", width: 468, height: 832 }, +}; + +const DURATION_PRESETS: Record = { + "5s": { label: "5s", length: 81 }, + "8s": { label: "8s", length: 129 }, +}; + +function isAsset(value: unknown): value is Asset { + return !!value && typeof value === "object" && typeof (value as Asset).id === "string" && typeof (value as Asset).asset_type === "string"; +} + +function isJob(value: unknown): value is Job { + return !!value && typeof value === "object" && typeof (value as Job).id === "string" && typeof (value as Job).status === "string"; +} + +export function DashboardClient() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [jobs, setJobs] = useState([]); + const [assets, setAssets] = useState([]); + const [composer, setComposer] = useState(initialState); + const [busy, setBusy] = useState(false); + const [uploading, setUploading] = useState(null); + const [error, setError] = useState(""); + const [menuOpen, setMenuOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [outputMenuOpen, setOutputMenuOpen] = useState(false); + const [picker, setPicker] = useState(null); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedAssetIds, setSelectedAssetIds] = useState([]); + const [trashBusy, setTrashBusy] = useState(false); + const [adminHealth, setAdminHealth] = useState(null); + const [jobsSummary, setJobsSummary] = useState(null); + const menuRef = useRef(null); + const outputMenuRef = useRef(null); + + const refresh = async () => { + try { + const [me, jobsData, assetsData] = await Promise.all([ + apiGet("/api/auth/me"), + apiGet("/api/jobs/"), + apiGet("/api/assets/"), + ]); + setUser(me); + setJobs(Array.isArray(jobsData) ? jobsData.filter(isJob).map((job) => ({ + ...job, + outputs: Array.isArray(job.outputs) ? job.outputs.filter(Boolean) : [], + events: Array.isArray(job.events) ? job.events.filter(Boolean) : [], + })) : []); + setAssets(Array.isArray(assetsData) ? assetsData.filter(isAsset) : []); + } catch { + router.push("/login"); + } + }; + + const refreshSettings = async () => { + try { + const [health, summary] = await Promise.all([ + apiGet("/api/admin/health"), + apiGet("/api/admin/jobs-summary"), + ]); + setAdminHealth(health); + setJobsSummary(summary); + } catch { + setAdminHealth(null); + setJobsSummary(null); + } + }; + + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), 6000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + if (settingsOpen) { + void refreshSettings(); + } + }, [settingsOpen]); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + if (outputMenuRef.current && !outputMenuRef.current.contains(event.target as Node)) { + setOutputMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + const assetsById = useMemo( + () => Object.fromEntries(assets.map((asset) => [asset.id, asset])), + [assets] + ); + + const groupedAssets = useMemo( + () => ({ + images: assets.filter((asset) => asset.asset_type === "image"), + motion: assets.filter((asset) => asset.asset_type === "video"), + audio: assets.filter((asset) => asset.asset_type === "audio"), + pose: assets.filter((asset) => asset.asset_type === "pose_sheet"), + }), + [assets] + ); + + const pickerAssets = useMemo(() => { + if (!picker) return []; + return assets.filter((asset) => picker.kinds.includes(asset.asset_type as AssetKind)); + }, [assets, picker]); + + const selectedGroundTruth = composer.groundTruthId ? assetsById[composer.groundTruthId] : null; + const selectedMotion = composer.motionId ? assetsById[composer.motionId] : null; + const selectedAudio = composer.audioId ? assetsById[composer.audioId] : null; + const selectedPose = composer.poseId ? assetsById[composer.poseId] : null; + const selectedReferences = composer.referenceIds + .map((id) => assetsById[id]) + .filter(Boolean) as Asset[]; + const selectedAspect = ASPECT_PRESETS[composer.aspectPreset]; + const selectedDuration = DURATION_PRESETS[composer.durationPreset]; + const uploadAsset = async ( + file: File, + assetType: AssetKind, + target?: "groundTruthId" | "motionId" | "audioId" | "poseId", + appendReference = false + ) => { + setUploading(assetType); + setError(""); + try { + const form = new FormData(); + form.append("file", file); + form.append("asset_type", assetType); + const uploaded = await apiPost("/api/assets/upload", form, true); + setAssets((current) => [uploaded, ...current]); + if (target) { + setComposer((current) => ({ ...current, [target]: uploaded.id })); + } + if (appendReference) { + setComposer((current) => ({ + ...current, + referenceIds: Array.from(new Set([uploaded.id, ...current.referenceIds])), + })); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploading(null); + } + }; + + const chooseExistingAsset = (asset: Asset) => { + if (!picker) return; + setComposer((current) => { + if (picker.target === "groundTruth") return { ...current, groundTruthId: asset.id }; + if (picker.target === "motion") return { ...current, motionId: asset.id }; + if (picker.target === "audio") return { ...current, audioId: asset.id }; + if (picker.target === "pose") return { ...current, poseId: asset.id }; + return { + ...current, + referenceIds: Array.from(new Set([asset.id, ...current.referenceIds])), + }; + }); + setPicker(null); + }; + + const removeSelectedAsset = (target: "groundTruth" | "motion" | "audio" | "pose" | "reference", assetId?: string) => { + setComposer((current) => { + if (target === "groundTruth") return { ...current, groundTruthId: "" }; + if (target === "motion") return { ...current, motionId: "" }; + if (target === "audio") return { ...current, audioId: "" }; + if (target === "pose") return { ...current, poseId: "" }; + return { ...current, referenceIds: current.referenceIds.filter((id) => id !== assetId) }; + }); + }; + + const createJob = async () => { + if (!composer.prompt.trim()) { + setError("Prompt is required."); + return; + } + if (!composer.groundTruthId) { + setError("Ground truth is required."); + return; + } + if (composer.mode === "audio" && !composer.audioId) { + setError("Select an audio file before generating."); + return; + } + + setBusy(true); + setError(""); + try { + if (composer.frameEndpoint === "end") { + throw new Error("End frame workflow is not live yet on the current Wan runtime."); + } + + for (let index = 0; index < composer.generationCount; index += 1) { + await apiPost("/api/jobs/", { + mode: composer.mode, + submode: composer.mode === "animate" ? composer.submode : null, + prompt: composer.prompt, + negative_prompt: composer.negativePrompt || null, + ground_truth_asset_id: composer.groundTruthId, + motion_asset_id: composer.mode === "animate" ? composer.motionId || null : null, + audio_asset_id: composer.mode === "audio" ? composer.audioId || null : null, + reference_asset_ids: composer.referenceIds, + pose_asset_id: composer.poseId || null, + settings: { + steps: 20, + cfg: 7, + seed: -1, + width: selectedAspect.width, + height: selectedAspect.height, + length: selectedDuration.length, + aspect_preset: composer.aspectPreset, + duration_preset: composer.durationPreset, + generation_count: composer.generationCount, + generation_index: index + 1, + model_preset: composer.modelPreset, + frame_endpoint: composer.frameEndpoint, + }, + }); + } + setComposer(initialState); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Job creation failed"); + } finally { + setBusy(false); + } + }; + + const moveSelectedAssetsToTrash = async () => { + if (selectedAssetIds.length === 0) return; + setTrashBusy(true); + setError(""); + try { + await apiPost("/api/assets/trash", { asset_ids: selectedAssetIds }); + const removed = new Set(selectedAssetIds); + setAssets((current) => current.filter((asset) => !removed.has(asset.id))); + setSelectedAssetIds([]); + setSelectionMode(false); + setComposer((current) => ({ + ...current, + groundTruthId: removed.has(current.groundTruthId) ? "" : current.groundTruthId, + motionId: removed.has(current.motionId) ? "" : current.motionId, + audioId: removed.has(current.audioId) ? "" : current.audioId, + poseId: removed.has(current.poseId) ? "" : current.poseId, + referenceIds: current.referenceIds.filter((id) => !removed.has(id)), + })); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to move assets to trash"); + } finally { + setTrashBusy(false); + } + }; + + const toggleAssetSelection = (assetId: string) => { + setSelectedAssetIds((current) => + current.includes(assetId) ? current.filter((id) => id !== assetId) : [...current, assetId] + ); + }; + + const logout = async () => { + await apiPost("/api/auth/logout", {}); + router.push("/login"); + }; + + return ( +
+
+
+

Animatrix

+

Generator

+
+ +
+ + + {menuOpen ? ( +
+
+
+ +
+
{user?.email ?? "Loading..."}
+
Logged in
+
+
+
+ + +
+ ) : null} +
+
+ +
+
+ {composer.activeSurface === "frames" ? ( +
+ { + setComposer((current) => ({ ...current, activeSurface: "frames", frameEndpoint: "start" })); + setPicker({ title: "Select Start Frame", kinds: ["image"], target: "groundTruth" }); + }} + /> +
+ +
+ { + setComposer((current) => ({ ...current, frameEndpoint: "end" })); + setError("End frame workflow is not live yet on the current Wan runtime."); + }} + /> +
+ ) : null} + +