feat: V2 microservices stack — backend services, gateway, JWT auth

Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+61
View File
@@ -0,0 +1,61 @@
# FlatRender V2 — Service Contracts
This directory defines all inter-service contracts. Services are loosely
coupled — they communicate via REST APIs (synchronous) and RabbitMQ
events (asynchronous). The browser connects to the Gateway over HTTPS +
WebSocket.
## Layout
```
contracts/
├── common/ # Reusable OpenAPI components (Error, Pagination, ...)
│ └── types.yaml
├── events/ # RabbitMQ event schemas (JSON Schema)
│ ├── README.md # Event catalog with routing keys
│ ├── render.yaml
│ ├── node.yaml
│ ├── identity.yaml
│ ├── file.yaml
│ ├── tenant.yaml
│ └── notification.yaml
├── websocket/ # WebSocket message protocol
│ └── render-progress.md
└── rest/ # Per-service OpenAPI 3.0 specs
├── identity.openapi.yaml # internal
├── content.openapi.yaml # internal
├── studio.openapi.yaml # internal
├── render.openapi.yaml # internal
├── file.openapi.yaml # internal
├── notification.openapi.yaml # internal
├── node-agent.openapi.yaml # called by render orchestrator
├── gateway-public.openapi.yaml # what frontend uses
└── reseller-api.openapi.yaml # B2B API (api-key auth)
```
## Authentication
| Surface | Auth | Carries |
|----------------------|---------------------------------|------------------------|
| Gateway public API | JWT (Bearer) | user_id, tenant_id |
| Reseller API | API key (X-API-Key + signature) | tenant_id, scopes |
| Internal service API | Service token (mTLS in prod) | service_name |
| Node Agent API | Shared secret (HMAC) | orchestrator_signature |
| WebSocket | JWT in query param `?token=...` | user_id, job_id |
## Versioning
- REST: URL prefix `/v1/...`
- Events: routing key suffix `.v1`
- WebSocket: protocol negotiation header
## Conventions
- All IDs are UUID v4
- All timestamps ISO 8601 with timezone (`2026-05-27T10:15:00Z`)
- All money fields are `_minor` integers (rial / cent)
- All durations are seconds (numeric)
- All file sizes are bytes (integer)
- Pagination: `?page=1&page_size=20` returning `PaginatedResponse<T>`
- Errors: `{ "error": { "code", "message", "details", "trace_id" } }`
- Tenant context: `X-Tenant-Id` header on internal calls (JWT carries it on public)
+305
View File
@@ -0,0 +1,305 @@
# =====================================================================
# Common OpenAPI Components — referenced by every service spec via $ref
# =====================================================================
openapi: 3.0.3
info:
title: FlatRender Common Types
version: "1.0"
components:
# -------------------------------------------------------------------
# Errors
# -------------------------------------------------------------------
schemas:
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code:
type: string
example: NOT_FOUND
message:
type: string
example: "Render job not found"
details:
type: object
additionalProperties: true
field_errors:
type: array
items:
type: object
properties:
field: { type: string }
message: { type: string }
trace_id:
type: string
format: uuid
timestamp:
type: string
format: date-time
# ---------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------
PaginationMeta:
type: object
required: [page, page_size, total, has_more]
properties:
page: { type: integer, minimum: 1, example: 1 }
page_size: { type: integer, minimum: 1, maximum: 200, example: 20 }
total: { type: integer, minimum: 0, example: 137 }
has_more: { type: boolean }
next_cursor:
type: string
nullable: true
description: Optional cursor for keyset pagination
PaginatedEnvelope:
type: object
required: [data, meta]
properties:
data:
type: array
items:
type: object
meta:
$ref: '#/components/schemas/PaginationMeta'
# ---------------------------------------------------------------
# Money
# ---------------------------------------------------------------
Money:
type: object
required: [amount_minor, currency]
properties:
amount_minor:
type: integer
format: int64
description: Amount in minor units (rial, cent, etc.)
example: 1500000
currency:
type: string
enum: [IRR, USD, EUR]
example: IRR
# ---------------------------------------------------------------
# Audit
# ---------------------------------------------------------------
Timestamps:
type: object
properties:
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
# ---------------------------------------------------------------
# Localized text (Persian + English)
# ---------------------------------------------------------------
LocalizedString:
type: object
additionalProperties:
type: string
example:
fa: "عنوان"
en: "Title"
# ---------------------------------------------------------------
# Tenant
# ---------------------------------------------------------------
TenantContext:
type: object
required: [tenant_id, slug]
properties:
tenant_id: { type: string, format: uuid }
slug: { type: string }
kind:
type: string
enum: [Internal, Reseller, Enterprise]
# ---------------------------------------------------------------
# User reference (lightweight)
# ---------------------------------------------------------------
UserRef:
type: object
required: [id]
properties:
id: { type: string, format: uuid }
full_name: { type: string, nullable: true }
avatar_url: { type: string, nullable: true }
# ---------------------------------------------------------------
# File reference
# ---------------------------------------------------------------
FileRef:
type: object
required: [id, url, file_type]
properties:
id: { type: string, format: uuid }
url: { type: string }
thumbnail_url: { type: string, nullable: true }
file_type:
type: string
enum: [Video, Image, Audio, Voiceover, Document, Other]
mime_type: { type: string, nullable: true }
size_bytes: { type: integer, format: int64 }
duration_sec: { type: number, nullable: true }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
# ---------------------------------------------------------------
# Enums (reused everywhere)
# ---------------------------------------------------------------
ChooseMode:
type: string
enum: [FIX, FLEXIBLE, MockUp, MusicVisualizer, VoiceOver]
Resolution:
type: string
enum: [HD, FullHD, TwoK, FourK]
SceneType:
type: string
enum: [Normal, Config, DesignStart, DesignEnd]
JustifyKind:
type: string
enum: [LEFT_JUSTIFY, CENTER_JUSTIFY, RIGHT_JUSTIFY, FULL_JUSTIFY]
RenderStep:
type: string
enum:
- Queued
- Preparing
- TemplateCache
- JsxGen
- Music
- Rendering
- Validating
- Repairing
- Optimisation
- Video
- Mixing
- Final
- Uploading
- Done
- Failed
- Cancelled
RenderPriorityQueue:
type: string
enum: [snapshot, vip, paid, preview, mockup, voiceover]
PriceType:
type: string
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
RenderQuality:
type: string
enum: [Low, Medium, High, Full, Lossless]
# -------------------------------------------------------------------
# Common headers
# -------------------------------------------------------------------
parameters:
TenantIdHeader:
name: X-Tenant-Id
in: header
required: true
schema: { type: string, format: uuid }
description: Tenant context (carried by JWT on public APIs)
RequestIdHeader:
name: X-Request-Id
in: header
required: false
schema: { type: string, format: uuid }
description: Optional client-supplied correlation ID
PageParam:
name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
PageSizeParam:
name: page_size
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
CursorParam:
name: cursor
in: query
schema: { type: string }
description: Keyset pagination cursor
# -------------------------------------------------------------------
# Standard responses
# -------------------------------------------------------------------
responses:
BadRequest:
description: Validation error or malformed request
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Unauthorized:
description: Missing or invalid auth
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Forbidden:
description: Authenticated but not allowed
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Conflict:
description: Resource conflict
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
TooManyRequests:
description: Rate limited
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
InternalError:
description: Unexpected server error
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# -------------------------------------------------------------------
# Security schemes
# -------------------------------------------------------------------
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT with claims sub, tenant_id, scope
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: B2B reseller API key (fr_live_..., fr_test_...)
ApiKeySignature:
type: apiKey
in: header
name: X-API-Signature
description: HMAC-SHA256 of (timestamp + body) using key secret
ServiceToken:
type: http
scheme: bearer
description: Inter-service short-lived JWT issued by control plane
NodeHmac:
type: apiKey
in: header
name: X-Node-Signature
description: HMAC for orchestrator <-> node-agent comms
+129
View File
@@ -0,0 +1,129 @@
# RabbitMQ Event Catalog
All async communication between services uses RabbitMQ. Events follow
strict naming: `{domain}.{entity}.{verb}.v{n}` (past tense).
## Exchanges
| Exchange | Type | Purpose |
|---------------------------|----------|--------------------------------------|
| `flatrender.events` | topic | Domain events (fan-out by routing) |
| `flatrender.render` | direct | Render job dispatch (per queue) |
| `flatrender.notify` | direct | Notification dispatch (per channel) |
| `flatrender.dlq` | fanout | Dead-letter queue |
## Common envelope
Every message body has this shape:
```json
{
"event_id": "uuid",
"event_type": "render.job.completed.v1",
"event_time": "2026-05-27T10:15:00Z",
"tenant_id": "uuid",
"user_id": "uuid",
"trace_id": "uuid",
"correlation_id": "uuid",
"producer": "render-orchestrator",
"data": { ... }
}
```
## Headers (AMQP)
| Header | Required | Notes |
|---------------------|----------|------------------------------------------------|
| `content-type` | yes | `application/json` |
| `content-encoding` | yes | `utf-8` |
| `x-event-type` | yes | Same as `event_type` (for routing convenience) |
| `x-tenant-id` | yes | For tenant-aware consumers |
| `x-trace-id` | yes | Distributed tracing |
| `x-retry-count` | optional | Incremented on requeue |
| `x-max-retries` | optional | Default 3 |
## Routing keys (topic exchange `flatrender.events`)
```
identity.user.registered.v1
identity.user.email_verified.v1
identity.user.banned.v1
identity.tenant.created.v1
identity.tenant.suspended.v1
identity.plan.activated.v1
identity.plan.expired.v1
identity.payment.succeeded.v1
identity.payment.failed.v1
identity.payment.refunded.v1
identity.api_key.created.v1
identity.api_key.revoked.v1
content.template.published.v1
content.template.unpublished.v1
content.font.installed.v1
content.svg_preview.generated.v1
studio.project.saved.v1
studio.project.deleted.v1
render.job.queued.v1
render.job.started.v1
render.job.progress.v1
render.job.completed.v1
render.job.failed.v1
render.job.cancelled.v1
render.snapshot.requested.v1
render.snapshot.ready.v1
node.online.v1
node.offline.v1
node.crashed.v1
node.heartbeat.v1
node.cache.updated.v1
file.uploaded.v1
file.processed.v1
file.deleted.v1
file.quota_warning.v1
file.quota_exceeded.v1
file.cleanup.scheduled.v1
file.cleanup.executed.v1
notification.created.v1
notification.delivered.v1
notification.failed.v1
tenant.usage.recorded.v1
tenant.webhook.fired.v1
tenant.webhook.failed.v1
```
## Render dispatch (direct exchange `flatrender.render`)
```
render.queue.snapshot
render.queue.vip
render.queue.paid
render.queue.preview
render.queue.mockup
render.queue.voiceover
```
Each queue has priority `x-max-priority: 10`. Job priority encoded in
message priority property.
## Notification dispatch (direct exchange `flatrender.notify`)
```
notify.channel.push
notify.channel.email
notify.channel.sms
notify.channel.telegram
notify.channel.webhook
```
## Dead-letter routing
Failed messages (after `x-max-retries` exhausted) are republished to
`flatrender.dlq` with original routing key preserved in
`x-original-routing-key` header.
+61
View File
@@ -0,0 +1,61 @@
# =====================================================================
# Content Events — published by Content Service
# =====================================================================
events:
content.template.published.v1:
routing_key: content.template.published.v1
description: A template (project_container) was published.
payload:
type: object
required: [container_id, slug, primary_mode]
properties:
container_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid, nullable: true }
slug: { type: string }
name: { type: string }
primary_mode: { type: string }
project_ids:
type: array
items: { type: string, format: uuid }
content.template.unpublished.v1:
routing_key: content.template.unpublished.v1
payload:
type: object
required: [container_id, reason]
properties:
container_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid, nullable: true }
slug: { type: string }
reason: { type: string }
content.font.installed.v1:
routing_key: content.font.installed.v1
description: Font registered as installed on render nodes.
payload:
type: object
required: [font_id, name, system_name, node_ids]
properties:
font_id: { type: string, format: uuid }
name: { type: string }
system_name: { type: string }
node_ids:
type: array
items: { type: string, format: uuid }
content.svg_preview.generated.v1:
routing_key: content.svg_preview.generated.v1
description: AI service produced an SVG color preview for a scene/project.
payload:
type: object
required: [svg_preview_id, svg_url, color_zones_count]
properties:
svg_preview_id: { type: string, format: uuid }
project_id: { type: string, format: uuid, nullable: true }
scene_id: { type: string, format: uuid, nullable: true }
svg_url: { type: string }
thumbnail_url: { type: string, nullable: true }
color_zones_count: { type: integer }
quality_score: { type: number, minimum: 0, maximum: 1 }
generated_by_ai: { type: boolean }
+114
View File
@@ -0,0 +1,114 @@
# =====================================================================
# File Events — published by File Service
# =====================================================================
events:
file.uploaded.v1:
routing_key: file.uploaded.v1
description: A file upload has completed and is ready to use.
payload:
type: object
required: [file_id, user_id, file_type, size_bytes, url]
properties:
file_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
folder_id: { type: string, format: uuid, nullable: true }
name: { type: string }
file_type: { type: string, enum: [Video, Image, Audio, Voiceover, Document, Other] }
mime_type: { type: string }
size_bytes: { type: integer, format: int64 }
url: { type: string }
thumbnail_url: { type: string, nullable: true }
duration_sec: { type: number, nullable: true }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
source:
type: string
enum: [upload, export, snapshot, voiceover_record, stock]
file.processed.v1:
routing_key: file.processed.v1
description: Post-upload processing (thumbnail, waveform, transcode) finished.
payload:
type: object
required: [file_id, status]
properties:
file_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
status: { type: string, enum: [Ready, Failed, Quarantined] }
thumbnail_url: { type: string, nullable: true }
waveform_generated: { type: boolean }
duration_sec: { type: number, nullable: true }
error_message: { type: string, nullable: true }
file.deleted.v1:
routing_key: file.deleted.v1
payload:
type: object
required: [file_id, user_id, size_bytes_freed]
properties:
file_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
size_bytes_freed: { type: integer, format: int64 }
deleted_by:
type: string
enum: [user, auto_cleanup, admin, quota_exceeded]
file.quota_warning.v1:
routing_key: file.quota_warning.v1
description: User passed 90% of storage quota.
payload:
type: object
required: [user_id, used_bytes, quota_bytes, percent_used]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
used_bytes: { type: integer, format: int64 }
quota_bytes: { type: integer, format: int64 }
percent_used: { type: number }
file.quota_exceeded.v1:
routing_key: file.quota_exceeded.v1
description: User hit 100% storage quota — uploads blocked.
payload:
type: object
required: [user_id, used_bytes, quota_bytes]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
used_bytes: { type: integer, format: int64 }
quota_bytes: { type: integer, format: int64 }
attempted_upload_size_bytes: { type: integer, format: int64, nullable: true }
file.cleanup.scheduled.v1:
routing_key: file.cleanup.scheduled.v1
description: An entity has been queued for automatic deletion.
payload:
type: object
required: [cleanup_id, entity_type, entity_id, scheduled_delete_at]
properties:
cleanup_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid, nullable: true }
user_id: { type: string, format: uuid, nullable: true }
entity_type:
type: string
enum: [Export, TempRenderFolder, OrphanedFile, UnusedUpload, SnapshotExpired]
entity_id: { type: string, format: uuid }
scheduled_delete_at: { type: string, format: date-time }
notify_user_at: { type: string, format: date-time, nullable: true }
file.cleanup.executed.v1:
routing_key: file.cleanup.executed.v1
payload:
type: object
required: [cleanup_id, status, bytes_freed]
properties:
cleanup_id: { type: string, format: uuid }
entity_type: { type: string }
entity_id: { type: string, format: uuid }
status: { type: string, enum: [Done, Skipped, Failed] }
bytes_freed: { type: integer, format: int64 }
error_message: { type: string, nullable: true }
+177
View File
@@ -0,0 +1,177 @@
# =====================================================================
# Identity Events — published by Identity Service
# =====================================================================
events:
identity.user.registered.v1:
routing_key: identity.user.registered.v1
description: New user account created (any tenant).
payload:
type: object
required: [user_id, tenant_id, register_mode]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
email: { type: string, nullable: true }
phone_number: { type: string, nullable: true }
full_name: { type: string, nullable: true }
register_mode:
type: string
enum: [Email, Mobile, Google, Telegram, SSO, Reseller]
affiliate_owner_id: { type: string, format: uuid, nullable: true }
registered_with_mobile_app: { type: boolean }
identity.user.email_verified.v1:
routing_key: identity.user.email_verified.v1
payload:
type: object
required: [user_id, email]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
email: { type: string }
identity.user.banned.v1:
routing_key: identity.user.banned.v1
payload:
type: object
required: [user_id, banned_by, reason]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
banned_by: { type: string, format: uuid }
reason: { type: string }
unblock_date: { type: string, format: date-time, nullable: true }
identity.tenant.created.v1:
routing_key: identity.tenant.created.v1
payload:
type: object
required: [tenant_id, slug, kind]
properties:
tenant_id: { type: string, format: uuid }
slug: { type: string }
name: { type: string }
kind: { type: string, enum: [Internal, Reseller, Enterprise] }
contact_email: { type: string, nullable: true }
identity.tenant.suspended.v1:
routing_key: identity.tenant.suspended.v1
payload:
type: object
required: [tenant_id, reason]
properties:
tenant_id: { type: string, format: uuid }
reason: { type: string }
suspended_at: { type: string, format: date-time }
identity.plan.activated.v1:
routing_key: identity.plan.activated.v1
payload:
type: object
required: [user_id, user_plan_id, plan_code, expires_at]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_plan_id: { type: string, format: uuid }
plan_id: { type: string, format: uuid }
plan_code: { type: string }
plan_name: { type: string }
seconds_charge: { type: integer }
storage_gb: { type: integer }
parallel_renders: { type: integer }
expires_at: { type: string, format: date-time }
payment_id: { type: string, format: uuid, nullable: true }
identity.plan.expired.v1:
routing_key: identity.plan.expired.v1
payload:
type: object
required: [user_id, user_plan_id]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_plan_id: { type: string, format: uuid }
plan_code: { type: string }
days_overdue: { type: integer }
identity.payment.succeeded.v1:
routing_key: identity.payment.succeeded.v1
payload:
type: object
required: [payment_id, user_id, amount_minor, gateway, action]
properties:
payment_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
amount_minor: { type: integer, format: int64 }
currency: { type: string }
gateway:
type: string
enum: [ZarinPal, IdPay, Bazaar, Stripe, Balance, Manual]
action:
type: string
enum: [PlanPurchase, BalanceCharge, ProjectRender, UserProject, StorageUpgrade, Other]
gateway_track_id: { type: string, nullable: true }
plan_id: { type: string, format: uuid, nullable: true }
affiliate_owner_id: { type: string, format: uuid, nullable: true }
affiliate_profit_minor: { type: integer, format: int64 }
identity.payment.failed.v1:
routing_key: identity.payment.failed.v1
payload:
type: object
required: [payment_id, user_id, gateway, failure_reason]
properties:
payment_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
amount_minor: { type: integer, format: int64 }
currency: { type: string }
gateway: { type: string }
failure_reason: { type: string }
action: { type: string }
identity.payment.refunded.v1:
routing_key: identity.payment.refunded.v1
payload:
type: object
required: [payment_id, user_id, refund_amount_minor, reason]
properties:
payment_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
refund_amount_minor: { type: integer, format: int64 }
currency: { type: string }
reason: { type: string }
refunded_to:
type: string
enum: [Balance, OriginalMethod, Plan]
identity.api_key.created.v1:
routing_key: identity.api_key.created.v1
payload:
type: object
required: [api_key_id, tenant_id, name, environment, created_by_user_id]
properties:
api_key_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
name: { type: string }
environment: { type: string, enum: [Live, Test] }
key_prefix: { type: string }
last4: { type: string }
scopes:
type: array
items: { type: string }
created_by_user_id: { type: string, format: uuid }
identity.api_key.revoked.v1:
routing_key: identity.api_key.revoked.v1
payload:
type: object
required: [api_key_id, tenant_id, revoked_by_user_id, reason]
properties:
api_key_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
revoked_by_user_id: { type: string, format: uuid }
reason: { type: string }
+122
View File
@@ -0,0 +1,122 @@
# =====================================================================
# Node Events — published by Node Agent → Render Orchestrator
# Routing: flatrender.events (topic) — key: node.*.v1
# =====================================================================
events:
# -------------------------------------------------------------------
# node.online.v1 — node agent starts up
# -------------------------------------------------------------------
node.online.v1:
routing_key: node.online.v1
payload:
type: object
required: [node_id, region, node_agent_version, current_ae_version]
properties:
node_id: { type: string, format: uuid }
node_ip: { type: string, format: ipv4 }
region: { type: string }
node_agent_version: { type: string }
current_ae_version: { type: string }
available_ae_versions:
type: array
items: { type: string }
ram_gb: { type: integer }
cpu_cores: { type: integer }
cache_used_gb: { type: integer }
cached_template_md5s:
type: array
items: { type: string }
# -------------------------------------------------------------------
# node.offline.v1 — graceful shutdown OR detected missed heartbeats
# -------------------------------------------------------------------
node.offline.v1:
routing_key: node.offline.v1
payload:
type: object
required: [node_id, reason]
properties:
node_id: { type: string, format: uuid }
reason: { type: string, enum: [Shutdown, HeartbeatLost, Maintenance, Disabled] }
last_heartbeat_at: { type: string, format: date-time }
current_job_id: { type: string, format: uuid, nullable: true }
# -------------------------------------------------------------------
# node.heartbeat.v1 — every 5s (NOT broadcast on topic exchange,
# sent direct to orchestrator HTTP endpoint OR a dedicated stream)
# Documented here for completeness.
# -------------------------------------------------------------------
node.heartbeat.v1:
routing_key: node.heartbeat.v1
transport: HTTP POST /v1/internal/nodes/{node_id}/heartbeat
payload:
type: object
required: [node_id, status, recorded_at]
properties:
node_id: { type: string, format: uuid }
status: { type: string, enum: [Ready, Busy, Crashed, Updating] }
recorded_at: { type: string, format: date-time }
cpu_pct: { type: integer, minimum: 0, maximum: 100 }
ram_available_mb: { type: integer }
ae_running: { type: boolean }
current_job_id: { type: string, format: uuid, nullable: true }
current_frame_job_id: { type: string, format: uuid, nullable: true }
current_frame: { type: integer, nullable: true }
cache_used_gb: { type: integer }
# -------------------------------------------------------------------
# node.crashed.v1 — AfterFX crashed mid-render
# -------------------------------------------------------------------
node.crashed.v1:
routing_key: node.crashed.v1
description: AE process exited unexpectedly while rendering.
payload:
type: object
required: [node_id, crashed_at]
properties:
node_id: { type: string, format: uuid }
render_job_id: { type: string, format: uuid, nullable: true }
frame_job_id: { type: string, format: uuid, nullable: true }
crashed_at: { type: string, format: date-time }
last_known_frame: { type: integer, nullable: true }
crash_signal: { type: string, nullable: true }
ae_version: { type: string }
error_log_tail: { type: string, description: "Last ~50 lines of AE log" }
log_file_url: { type: string, nullable: true }
auto_recovery_started: { type: boolean }
# -------------------------------------------------------------------
# node.cache.updated.v1 — template cache changed (download or evict)
# -------------------------------------------------------------------
node.cache.updated.v1:
routing_key: node.cache.updated.v1
payload:
type: object
required: [node_id, action, project_id, aep_file_md5]
properties:
node_id: { type: string, format: uuid }
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
project_id: { type: string, format: uuid }
aep_file_md5: { type: string }
file_size_bytes: { type: integer, format: int64 }
cache_used_gb: { type: integer, description: "Total cache size after action" }
duration_ms: { type: integer, nullable: true }
error_message: { type: string, nullable: true }
# -------------------------------------------------------------------
# node.frame.completed.v1 — single frame done (high-frequency)
# NOT on topic; sent via direct push to orchestrator.
# -------------------------------------------------------------------
node.frame.completed.v1:
routing_key: node.frame.completed.v1
transport: HTTP POST /v1/internal/render/jobs/{job_id}/frames
payload:
type: object
required: [render_job_id, frame_job_id, frame_number]
properties:
render_job_id: { type: string, format: uuid }
frame_job_id: { type: string, format: uuid }
frame_number: { type: integer }
file_size_bytes: { type: integer }
completed_at: { type: string, format: date-time }
@@ -0,0 +1,66 @@
# =====================================================================
# Notification Events
# =====================================================================
events:
notification.created.v1:
routing_key: notification.created.v1
description: A new in-app notification was created (also fans out to channels).
payload:
type: object
required: [notification_id, user_id, notification_type, title, message]
properties:
notification_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
notification_type:
type: string
enum: [RenderCompleted, RenderFailed, RenderProgress,
PlanExpiring, PlanExpired, PaymentSuccess, PaymentFailed,
StorageWarning, StorageFull, ExportExpiring, ExportDeleted,
GiftEarned, QuestCompleted, LevelUp,
AccountSecurity, SystemAnnouncement, TenantInvite,
Marketing, Other]
priority: { type: string, enum: [Low, Normal, High, Urgent] }
title: { type: string }
message: { type: string }
action_url: { type: string, nullable: true }
# Fan-out routing — which channels to deliver to
channels:
type: array
items: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
notification.delivered.v1:
routing_key: notification.delivered.v1
payload:
type: object
required: [delivery_id, notification_id, channel, status]
properties:
delivery_id: { type: string, format: uuid }
notification_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
channel: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
status: { type: string, enum: [Sent, Delivered] }
provider: { type: string }
provider_message_id: { type: string, nullable: true }
sent_at: { type: string, format: date-time }
delivered_at: { type: string, format: date-time, nullable: true }
notification.failed.v1:
routing_key: notification.failed.v1
payload:
type: object
required: [delivery_id, channel, error_message]
properties:
delivery_id: { type: string, format: uuid }
notification_id: { type: string, format: uuid, nullable: true }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
channel: { type: string }
attempt: { type: integer }
max_attempts: { type: integer }
error_code: { type: string, nullable: true }
error_message: { type: string }
will_retry: { type: boolean }
next_retry_at: { type: string, format: date-time, nullable: true }
+219
View File
@@ -0,0 +1,219 @@
# =====================================================================
# Render Events — published by Render Orchestrator
# Routing: flatrender.events (topic) — key: render.*.v1
# =====================================================================
$schema: "http://json-schema.org/draft-07/schema#"
title: Render Event Schemas
type: object
definitions:
EventEnvelope:
type: object
required: [event_id, event_type, event_time, tenant_id, data]
properties:
event_id: { type: string, format: uuid }
event_type: { type: string }
event_time: { type: string, format: date-time }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
trace_id: { type: string, format: uuid }
correlation_id: { type: string, format: uuid }
producer: { type: string, const: render-orchestrator }
data: { type: object }
events:
# -------------------------------------------------------------------
# render.job.queued.v1
# Emitted when a job is accepted into the queue.
# Consumed by: notification (none yet), analytics
# -------------------------------------------------------------------
render.job.queued.v1:
routing_key: render.job.queued.v1
description: New render job has been accepted into a priority queue.
payload:
type: object
required: [render_job_id, saved_project_id, priority_queue, price_type, region]
properties:
render_job_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
priority_queue:
type: string
enum: [snapshot, vip, paid, preview, mockup, voiceover]
priority_score: { type: integer, minimum: 0, maximum: 100 }
price_type:
type: string
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
paid_price_minor: { type: integer, format: int64 }
region: { type: string }
duration_sec: { type: number }
resolution: { type: string }
is_60_fps: { type: boolean }
has_music: { type: boolean }
has_sfx: { type: boolean }
has_voiceover: { type: boolean }
# -------------------------------------------------------------------
# render.job.started.v1
# Emitted once nodes are assigned and rendering begins.
# -------------------------------------------------------------------
render.job.started.v1:
routing_key: render.job.started.v1
description: Render job has begun processing on at least one node.
payload:
type: object
required: [render_job_id, started_at]
properties:
render_job_id: { type: string, format: uuid }
started_at: { type: string, format: date-time }
node_ids:
type: array
items: { type: string, format: uuid }
region: { type: string }
total_frames: { type: integer }
# -------------------------------------------------------------------
# render.job.progress.v1 (high-frequency, optional pub/sub)
# NOT broadcast on the main events exchange — only on per-job
# ephemeral fanout for WebSocket fan-out. Schema documented here.
# -------------------------------------------------------------------
render.job.progress.v1:
routing_key: render.job.progress.{job_id}
description: Progress tick for live UI update.
exchange: render.progress # separate dedicated exchange (auto-delete fanout)
payload:
type: object
required: [render_job_id, step, progress]
properties:
render_job_id: { type: string, format: uuid }
step:
type: string
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
Rendering, Validating, Repairing, Optimisation, Video,
Mixing, Final, Uploading, Done, Failed, Cancelled]
progress: { type: integer, minimum: 0, maximum: 100 }
current_frame: { type: integer, nullable: true }
total_frames: { type: integer, nullable: true }
eta_seconds: { type: integer, nullable: true }
preview_b64:
type: string
nullable: true
description: Last rendered frame thumbnail (small, ~5-15 KB)
message: { type: string, nullable: true }
# -------------------------------------------------------------------
# render.job.completed.v1
# Consumed by: notification, studio (mark project), file (cleanup),
# tenant (usage metering), webhook dispatcher
# -------------------------------------------------------------------
render.job.completed.v1:
routing_key: render.job.completed.v1
description: Render job successfully produced an export.
payload:
type: object
required: [render_job_id, export_id, output_url, duration_sec, size_bytes]
properties:
render_job_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
export_id: { type: string, format: uuid }
output_url: { type: string }
thumbnail_url: { type: string, nullable: true }
duration_sec: { type: number }
size_bytes: { type: integer, format: int64 }
resolution: { type: string }
width: { type: integer }
height: { type: integer }
render_compute_seconds: { type: integer, description: "Sum of node-seconds spent" }
node_ids_used:
type: array
items: { type: string, format: uuid }
price_type: { type: string }
paid_price_minor: { type: integer, format: int64 }
# -------------------------------------------------------------------
# render.job.failed.v1
# Consumed by: notification, identity (refund), tenant (metering)
# -------------------------------------------------------------------
render.job.failed.v1:
routing_key: render.job.failed.v1
description: Render job failed permanently (after retries exhausted).
payload:
type: object
required: [render_job_id, failed_at_step, error_message]
properties:
render_job_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
failed_at_step:
type: string
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
Rendering, Validating, Repairing, Optimisation, Video,
Mixing, Final, Uploading, Done, Failed, Cancelled]
error_message: { type: string }
error_code: { type: string, nullable: true }
retry_count: { type: integer }
refund_required: { type: boolean, default: true }
price_type: { type: string }
paid_price_minor: { type: integer, format: int64 }
node_ids_attempted:
type: array
items: { type: string, format: uuid }
# -------------------------------------------------------------------
# render.job.cancelled.v1
# User cancelled via UI before completion.
# -------------------------------------------------------------------
render.job.cancelled.v1:
routing_key: render.job.cancelled.v1
description: Render job was cancelled by user.
payload:
type: object
required: [render_job_id, cancelled_by_user_id]
properties:
render_job_id: { type: string, format: uuid }
cancelled_by_user_id: { type: string, format: uuid }
cancelled_at_step: { type: string }
progress_when_cancelled: { type: integer }
refund_required: { type: boolean, default: true }
# -------------------------------------------------------------------
# render.snapshot.requested.v1
# User asked for a single-frame snapshot of a scene.
# -------------------------------------------------------------------
render.snapshot.requested.v1:
routing_key: render.snapshot.requested.v1
description: Scene snapshot was queued.
payload:
type: object
required: [snapshot_id, saved_project_id, scene_key, frame_number]
properties:
snapshot_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer }
cached:
type: boolean
description: True if served from cache (no render needed)
# -------------------------------------------------------------------
# render.snapshot.ready.v1
# Single-frame snapshot finished.
# -------------------------------------------------------------------
render.snapshot.ready.v1:
routing_key: render.snapshot.ready.v1
description: Snapshot image is ready.
payload:
type: object
required: [snapshot_id, image_url]
properties:
snapshot_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer }
image_url: { type: string }
thumbnail_url: { type: string, nullable: true }
width: { type: integer }
height: { type: integer }
size_bytes: { type: integer }
duration_ms: { type: integer }
expires_at: { type: string, format: date-time }
+32
View File
@@ -0,0 +1,32 @@
# =====================================================================
# Studio Events — published by Studio Service
# =====================================================================
events:
studio.project.saved.v1:
routing_key: studio.project.saved.v1
description: User saved their project (used to trigger autosave events, analytics).
payload:
type: object
required: [saved_project_id, user_id]
properties:
saved_project_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
name: { type: string }
choose_mode: { type: string }
scene_count: { type: integer }
is_first_save: { type: boolean }
studio.project.deleted.v1:
routing_key: studio.project.deleted.v1
description: User deleted a saved project (moved to trash or hard-deleted).
payload:
type: object
required: [saved_project_id, user_id, hard_delete]
properties:
saved_project_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
hard_delete: { type: boolean }
+72
View File
@@ -0,0 +1,72 @@
# =====================================================================
# Tenant Events — multi-tenancy / reseller-specific
# =====================================================================
events:
tenant.usage.recorded.v1:
routing_key: tenant.usage.recorded.v1
description: Daily usage aggregate published (by usage aggregator cron).
payload:
type: object
required: [tenant_id, usage_date, renders_completed, render_seconds]
properties:
tenant_id: { type: string, format: uuid }
usage_date: { type: string, format: date }
renders_started: { type: integer }
renders_completed: { type: integer }
renders_failed: { type: integer }
render_seconds: { type: integer, format: int64 }
render_compute_sec: { type: integer, format: int64 }
storage_bytes: { type: integer, format: int64 }
api_calls: { type: integer, format: int64 }
active_users: { type: integer }
new_users: { type: integer }
amount_billed_minor: { type: integer, format: int64 }
currency: { type: string }
tenant.webhook.fired.v1:
routing_key: tenant.webhook.fired.v1
description: A webhook was successfully delivered to a reseller.
payload:
type: object
required: [webhook_id, tenant_id, event_type, response_status]
properties:
webhook_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
delivery_id: { type: string, format: uuid }
event_type: { type: string }
request_url: { type: string }
response_status: { type: integer }
duration_ms: { type: integer }
attempt: { type: integer }
tenant.webhook.failed.v1:
routing_key: tenant.webhook.failed.v1
description: A webhook delivery exhausted retries.
payload:
type: object
required: [webhook_id, tenant_id, event_type, last_status, last_error]
properties:
webhook_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
delivery_id: { type: string, format: uuid }
event_type: { type: string }
request_url: { type: string }
last_status: { type: integer, nullable: true }
last_error: { type: string }
attempts: { type: integer }
webhook_disabled: { type: boolean, description: "True if auto-disabled" }
tenant.api.rate_limited.v1:
routing_key: tenant.api.rate_limited.v1
description: A tenant exceeded its API rate limit (informational).
payload:
type: object
required: [tenant_id, api_key_id, limit_rpm, window_start]
properties:
tenant_id: { type: string, format: uuid }
api_key_id: { type: string, format: uuid }
limit_rpm: { type: integer }
actual_rpm: { type: integer }
window_start: { type: string, format: date-time }
ip_address: { type: string }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+823
View File
@@ -0,0 +1,823 @@
openapi: 3.0.3
info:
title: FlatRender Render Orchestrator (internal)
version: 1.0.0
description: |
Render job orchestration, node pool management, snapshots, exports.
Owned by Go service. The browser connects to WebSocket via Gateway
for live progress.
servers:
- url: http://render-svc.internal/v1
security:
- BearerAuth: []
- ServiceToken: []
tags:
- name: Jobs
- name: Snapshots
- name: Exports
- name: Nodes
- name: Admin
- name: Internal
paths:
# ===================== JOBS =====================
/renders:
get:
tags: [Jobs]
summary: List user's render jobs
parameters:
- { name: status, in: query, schema: { type: string } }
- { name: page, in: query, schema: { type: integer } }
- { name: page_size, in: query, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/RenderJob' } }
meta: { $ref: '#/components/schemas/PaginationMeta' }
post:
tags: [Jobs]
summary: Submit new render job
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJobCreate' }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJob' }
'402': { description: Insufficient balance/charge }
/renders/{job_id}:
get:
tags: [Jobs]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJobDetail' }
/renders/{job_id}/cancel:
post:
tags: [Jobs]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
content:
application/json:
schema:
type: object
properties:
reason: { type: string }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
cancelled: { type: boolean }
refund_amount_minor: { type: integer, format: int64 }
/renders/{job_id}/retry:
post:
tags: [Jobs]
summary: Retry a failed render (uses same config)
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJob' }
/renders/{job_id}/progress:
get:
tags: [Jobs]
summary: |
Fallback polling endpoint when WebSocket isn't usable.
Browser primary is WS at /ws/v1/render/{job_id}.
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderProgress' }
/renders/{job_id}/logs:
get:
tags: [Jobs]
summary: Get render execution logs (admin or owner)
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: level, in: query, schema: { type: string, enum: [debug, info, warn, error] } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
logs:
type: array
items:
type: object
properties:
timestamp: { type: string, format: date-time }
level: { type: string }
node_id: { type: string, format: uuid, nullable: true }
message: { type: string }
meta: { type: object }
# ===================== SNAPSHOTS =====================
/snapshots:
post:
tags: [Snapshots]
summary: Request single-frame snapshot of a scene
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [saved_project_id, scene_key, frame_number]
properties:
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer, minimum: 0 }
responses:
'202':
description: Snapshot queued (or returned immediately if cached)
content:
application/json:
schema:
type: object
properties:
snapshot_id: { type: string, format: uuid }
status: { type: string, enum: [Cached, Pending, Rendering] }
image_url: { type: string, nullable: true }
thumbnail_url: { type: string, nullable: true }
expires_at: { type: string, format: date-time, nullable: true }
/snapshots/{snapshot_id}:
get:
tags: [Snapshots]
parameters:
- { name: snapshot_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Snapshot' }
# ===================== EXPORTS =====================
/exports:
get:
tags: [Exports]
summary: List user's exports
parameters:
- { name: page, in: query, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/Export' } }
meta: { $ref: '#/components/schemas/PaginationMeta' }
/exports/{export_id}:
get:
tags: [Exports]
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/ExportDetail' }
delete:
tags: [Exports]
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Deleted }
/exports/{export_id}/extend:
post:
tags: [Exports]
summary: Extend auto-delete date (paid feature)
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
days: { type: integer, minimum: 1, maximum: 365 }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
new_auto_delete_date: { type: string, format: date-time }
/exports/{export_id}/download-url:
get:
tags: [Exports]
summary: Get presigned MinIO URL (short-lived)
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: format, in: query, schema: { type: string, default: mp4 } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
url: { type: string }
expires_at: { type: string, format: date-time }
# ===================== NODES (admin) =====================
/nodes:
get:
tags: [Nodes]
summary: List nodes
parameters:
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/RenderNode' } }
post:
tags: [Nodes]
summary: (Admin) Register new node
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNodeCreate' }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNode' }
/nodes/{node_id}:
get:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNodeDetail' }
patch:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
priority: { type: integer }
is_active: { type: boolean }
accepts_new_jobs: { type: boolean }
node_kind: { type: string }
owner_user_id: { type: string, format: uuid }
next_maintenance_at: { type: string, format: date-time }
maintenance_reason: { type: string }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNode' }
/nodes/{node_id}/restart:
post:
tags: [Nodes]
summary: Reboot node OS
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'202': { description: Restart issued }
/nodes/{node_id}/release:
post:
tags: [Nodes]
summary: Force-release node from any current job
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Released }
/nodes/{node_id}/close-ae:
post:
tags: [Nodes]
summary: Force-kill AfterFX on a node
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: AE closed }
/nodes/{node_id}/health:
get:
tags: [Nodes]
summary: Current node health
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/NodeHealth' }
/nodes/{node_id}/health/history:
get:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: from, in: query, schema: { type: string, format: date-time } }
- { name: to, in: query, schema: { type: string, format: date-time } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/NodeHealth' }
/nodes/{node_id}/crashes:
get:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/NodeCrash' } }
/node-updates:
get:
tags: [Nodes]
summary: Available node software updates
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/NodeUpdate' } }
/node-updates/{update_id}/rollout:
post:
tags: [Nodes]
summary: Trigger rollout to selected nodes
parameters:
- { name: update_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
node_ids: { type: array, items: { type: string, format: uuid } }
responses:
'202': { description: Rollout queued }
# ===================== INTERNAL (called by node agents) =====================
/internal/nodes/{node_id}/heartbeat:
post:
tags: [Internal]
summary: Node sends heartbeat (every 5s)
security: [NodeHmac: []]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NodeHeartbeat' }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
next_heartbeat_in_sec: { type: integer }
pending_commands:
type: array
description: e.g. "cancel current job", "update software"
items: { type: object }
/internal/nodes/{node_id}/online:
post:
tags: [Internal]
summary: Node agent reports it just started
security: [NodeHmac: []]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
node_agent_version: { type: string }
current_ae_version: { type: string }
available_ae_versions: { type: array, items: { type: string } }
ram_gb: { type: integer }
cpu_cores: { type: integer }
cache_used_gb: { type: integer }
cached_template_md5s: { type: array, items: { type: string } }
responses:
'200': { description: Acknowledged }
/internal/render/jobs/{job_id}/frames:
post:
tags: [Internal]
summary: Node pushes per-frame progress
security: [NodeHmac: []]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [frame_job_id, frame_number]
properties:
frame_job_id: { type: string, format: uuid }
frame_number: { type: integer }
file_size_bytes: { type: integer }
completed_at: { type: string, format: date-time }
responses:
'204': { description: Recorded }
/internal/render/jobs/{job_id}/crash:
post:
tags: [Internal]
summary: Node reports an AE crash on this job
security: [NodeHmac: []]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [node_id]
properties:
node_id: { type: string, format: uuid }
frame_job_id: { type: string, format: uuid }
last_known_frame: { type: integer }
crash_signal: { type: string }
ae_version: { type: string }
error_log_tail: { type: string }
log_file_url: { type: string }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
action_recommended:
type: string
enum: [ResetAndRestart, ReassignWork, RestartNode]
reassigned_to_node_id:
type: string
format: uuid
nullable: true
/internal/render/jobs/{job_id}/replica-ready:
post:
tags: [Internal]
summary: Node reports replica .aep saved (after JSX run)
security: [NodeHmac: []]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [node_id, replica_path]
properties:
node_id: { type: string, format: uuid }
replica_path: { type: string }
replica_md5: { type: string }
responses:
'204': { description: Acknowledged }
/internal/nodes/{node_id}/cache-update:
post:
tags: [Internal]
summary: Node reports a template cache change
security: [NodeHmac: []]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action, aep_file_md5]
properties:
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
project_id: { type: string, format: uuid }
aep_file_md5: { type: string }
file_size_bytes: { type: integer, format: int64 }
cache_used_gb: { type: integer }
error_message: { type: string }
responses:
'204': { description: Recorded }
components:
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
ServiceToken: { type: http, scheme: bearer }
NodeHmac:
type: apiKey
in: header
name: X-Node-Signature
schemas:
PaginationMeta:
type: object
properties:
page: { type: integer }
page_size: { type: integer }
total: { type: integer }
has_more: { type: boolean }
RenderJob:
type: object
properties:
id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
name: { type: string }
step: { type: string }
render_progress: { type: integer }
priority_queue: { type: string }
price_type: { type: string }
paid_price_minor: { type: integer, format: int64 }
quality: { type: string }
resolution: { type: string }
frame_rate: { type: integer }
duration_sec: { type: number }
has_voiceover: { type: boolean }
image_preview_b64: { type: string, nullable: true }
failed_message: { type: string, nullable: true }
export_id: { type: string, format: uuid, nullable: true }
queued_at: { type: string, format: date-time }
started_at: { type: string, format: date-time, nullable: true }
completed_at: { type: string, format: date-time, nullable: true }
RenderJobCreate:
type: object
required: [saved_project_id, quality, resolution]
properties:
saved_project_id: { type: string, format: uuid }
quality: { type: string, enum: [Low, Medium, High, Full, Lossless] }
resolution: { type: string, enum: [HD, FullHD, TwoK, FourK] }
frame_rate: { type: integer, enum: [24, 25, 30, 60] }
is_60_fps: { type: boolean }
price_type:
type: string
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
discount_code: { type: string }
support_flatrender: { type: boolean }
tell_me_when_done: { type: boolean }
preferred_region: { type: string }
RenderJobDetail:
allOf:
- $ref: '#/components/schemas/RenderJob'
- type: object
properties:
frame_jobs:
type: array
items: { $ref: '#/components/schemas/FrameJob' }
retry_count: { type: integer }
repair_attempts: { type: integer }
export: { $ref: '#/components/schemas/Export', nullable: true }
FrameJob:
type: object
properties:
id: { type: string, format: uuid }
node_id: { type: string, format: uuid }
start_frame: { type: integer }
end_frame: { type: integer }
order_value: { type: integer }
status: { type: string }
frames_rendered: { type: integer }
frames_validated: { type: integer }
attempt: { type: integer }
last_error: { type: string, nullable: true }
RenderProgress:
type: object
properties:
job_id: { type: string, format: uuid }
step: { type: string }
progress: { type: integer }
current_frame: { type: integer, nullable: true }
total_frames: { type: integer, nullable: true }
eta_seconds: { type: integer, nullable: true }
preview_b64: { type: string, nullable: true }
active_nodes: { type: integer }
message: { type: string, nullable: true }
Snapshot:
type: object
properties:
id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer }
status: { type: string }
image_url: { type: string, nullable: true }
thumbnail_url: { type: string, nullable: true }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
duration_ms: { type: integer, nullable: true }
expires_at: { type: string, format: date-time }
requested_at: { type: string, format: date-time }
completed_at: { type: string, format: date-time, nullable: true }
Export:
type: object
properties:
id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
path: { type: string }
image: { type: string }
size_bytes: { type: integer, format: int64 }
duration_sec: { type: number }
width: { type: integer }
height: { type: integer }
file_extension: { type: string }
file_type: { type: string }
render_quality: { type: string }
create_type: { type: string }
produce_date: { type: string, format: date-time }
auto_delete_date: { type: string, format: date-time }
ExportDetail:
allOf:
- $ref: '#/components/schemas/Export'
- type: object
properties:
files:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
thumbnail: { type: string }
path: { type: string }
size_bytes: { type: integer, format: int64 }
file_type: { type: string }
RenderNode:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
region: { type: string }
node_ip: { type: string }
worker_port: { type: integer }
status: { type: string }
node_kind: { type: string }
owner_user_id: { type: string, format: uuid, nullable: true }
current_ae_version: { type: string }
node_agent_version: { type: string }
ram_gb: { type: integer }
cpu_cores: { type: integer }
priority: { type: integer }
is_active: { type: boolean }
accepts_new_jobs: { type: boolean }
last_heartbeat_at: { type: string, format: date-time }
current_job_id: { type: string, format: uuid, nullable: true }
last_cpu_pct: { type: integer }
last_ram_available_mb: { type: integer }
ae_running: { type: boolean }
cache_used_gb: { type: integer }
cached_template_count: { type: integer }
lifetime_task_count: { type: integer, format: int64 }
lifetime_crash_count: { type: integer }
consecutive_failures: { type: integer }
RenderNodeCreate:
type: object
required: [name, region, node_ip, worker_port, current_ae_version]
properties:
name: { type: string }
region: { type: string }
node_ip: { type: string }
worker_port: { type: integer }
current_ae_version: { type: string }
ram_gb: { type: integer }
cpu_cores: { type: integer }
node_kind: { type: string }
owner_user_id: { type: string, format: uuid }
priority: { type: integer }
RenderNodeDetail:
allOf:
- $ref: '#/components/schemas/RenderNode'
- type: object
properties:
available_ae_versions: { type: array, items: { type: string } }
cached_template_md5s: { type: array, items: { type: string } }
last_maintenance_at: { type: string, format: date-time }
next_maintenance_at: { type: string, format: date-time, nullable: true }
NodeHealth:
type: object
properties:
node_id: { type: string, format: uuid }
recorded_at: { type: string, format: date-time }
status: { type: string }
cpu_pct: { type: integer }
ram_available_mb: { type: integer }
ae_running: { type: boolean }
current_job_id: { type: string, format: uuid, nullable: true }
current_frame: { type: integer, nullable: true }
cache_used_gb: { type: integer }
NodeHeartbeat:
allOf:
- $ref: '#/components/schemas/NodeHealth'
NodeCrash:
type: object
properties:
id: { type: string, format: uuid }
node_id: { type: string, format: uuid }
render_job_id: { type: string, format: uuid, nullable: true }
crashed_at: { type: string, format: date-time }
last_known_frame: { type: integer, nullable: true }
crash_signal: { type: string }
error_log: { type: string }
log_file_url: { type: string }
auto_recovered: { type: boolean }
recovery_action: { type: string }
recovered_at: { type: string, format: date-time, nullable: true }
NodeUpdate:
type: object
properties:
id: { type: string, format: uuid }
update_file_name: { type: string }
update_number: { type: integer }
description: { type: string }
target_ae_version: { type: string }
in_update_queue: { type: boolean }
rolled_out_to_node_ids: { type: array, items: { type: string, format: uuid } }
create_date: { type: string, format: date-time }
+661
View File
@@ -0,0 +1,661 @@
openapi: 3.0.3
info:
title: FlatRender Studio Service (internal)
version: 1.0.0
description: |
User's saved projects (the editor's state). Includes voiceover +
audio mix settings.
servers:
- url: http://studio-svc.internal/v1
security:
- BearerAuth: []
- ServiceToken: []
tags:
- name: SavedProjects
- name: SavedScenes
- name: Audio
- name: Internal
paths:
/saved-projects:
get:
tags: [SavedProjects]
summary: List user's saved projects
parameters:
- { name: q, in: query, schema: { type: string } }
- { name: type, in: query, schema: { type: string, enum: [Draft, Active, Archived, Trash] } }
- { name: page, in: query, schema: { type: integer } }
- { name: page_size, in: query, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SavedProjectSummary' } }
meta: { $ref: '#/components/schemas/PaginationMeta' }
post:
tags: [SavedProjects]
summary: Create new saved project from a template
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [original_project_id]
properties:
original_project_id: { type: string, format: uuid }
name: { type: string }
preset_story_id: { type: string, format: uuid }
copy_default_values: { type: boolean, default: true }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
/saved-projects/{id}:
get:
tags: [SavedProjects]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectFull' }
patch:
tags: [SavedProjects]
summary: Update top-level fields (name, audio, etc.)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectUpdate' }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
delete:
tags: [SavedProjects]
summary: Soft-delete (move to trash)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Trashed }
/saved-projects/{id}/restore:
post:
tags: [SavedProjects]
summary: Restore from trash
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
/saved-projects/{id}/duplicate:
post:
tags: [SavedProjects]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
content:
application/json:
schema:
type: object
properties:
new_name: { type: string }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
/saved-projects/{id}/autosave:
put:
tags: [SavedProjects]
summary: Autosave entire project graph (debounced from UI)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectFull' }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
saved_at: { type: string, format: date-time }
version: { type: integer }
# ===================== AUDIO (NEW) =====================
/saved-projects/{id}/audio:
get:
tags: [Audio]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
put:
tags: [Audio]
summary: Update audio mix
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
/saved-projects/{id}/voiceover:
post:
tags: [Audio]
summary: |
Upload or record voiceover. Returns target file_id.
Use file service upload endpoints for actual binary; this
attaches an existing file to the project.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_id]
properties:
file_id: { type: string, format: uuid, description: existing user_file_id }
recorded_in_browser: { type: boolean, default: false }
volume: { type: number, minimum: 0, maximum: 1, default: 1.0 }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
delete:
tags: [Audio]
summary: Remove voiceover
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Removed }
/saved-projects/{id}/music:
put:
tags: [Audio]
summary: Set music track (from library or upload)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
music_track_id: { type: string, format: uuid }
music_file_id: { type: string, format: uuid }
volume: { type: number, minimum: 0, maximum: 1 }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
# ===================== SCENES =====================
/saved-projects/{id}/scenes:
get:
tags: [SavedScenes]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SavedSceneFull' } }
post:
tags: [SavedScenes]
summary: Add a scene from project template
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [original_scene_id]
properties:
original_scene_id: { type: string, format: uuid }
sort: { type: integer }
scene_length_sec: { type: number }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedSceneFull' }
/saved-scenes/{scene_id}:
patch:
tags: [SavedScenes]
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
scene_length_sec: { type: number }
manual_color_selection: { type: boolean }
selected_color_preset_id: { type: integer, format: int64 }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedScene' }
delete:
tags: [SavedScenes]
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'204': { description: Removed }
/saved-projects/{id}/scenes/reorder:
post:
tags: [SavedScenes]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ordered_ids]
properties:
ordered_ids:
type: array
items: { type: integer, format: int64 }
responses:
'204': { description: Reordered }
/saved-scenes/{scene_id}/contents:
put:
tags: [SavedScenes]
summary: Bulk-update contents for a scene
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
contents:
type: array
items: { $ref: '#/components/schemas/SavedSceneContent' }
responses:
'200': { description: Updated }
/saved-scenes/{scene_id}/colors:
put:
tags: [SavedScenes]
summary: Bulk-update colors for a scene
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
colors:
type: array
items: { $ref: '#/components/schemas/SavedSceneColor' }
responses:
'200': { description: Updated }
/saved-projects/{id}/shared-colors:
put:
tags: [SavedProjects]
summary: Bulk update project-level shared colors
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
colors:
type: array
items: { $ref: '#/components/schemas/SavedSharedColor' }
responses:
'200': { description: Updated }
/saved-projects/{id}/shared-layers:
put:
tags: [SavedProjects]
summary: Bulk update project-level shared layers
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
layers:
type: array
items: { $ref: '#/components/schemas/SavedSharedLayer' }
responses:
'200': { description: Updated }
# ===================== INTERNAL =====================
/internal/saved-projects/{id}/snapshot-for-render:
get:
tags: [Internal]
summary: |
Called by Render Orchestrator to get the full JSX-ready payload.
Returns everything needed to generate JSX (FIX/FLEXIBLE/Mockup/MV).
security: [ServiceToken: []]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectSnapshot' }
components:
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
ServiceToken: { type: http, scheme: bearer }
schemas:
PaginationMeta:
type: object
properties:
page: { type: integer }
page_size: { type: integer }
total: { type: integer }
has_more: { type: boolean }
SavedProjectSummary:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
image: { type: string }
type: { type: string }
original_project_id: { type: string, format: uuid }
original_project_name: { type: string }
original_container_slug: { type: string }
choose_mode: { type: string }
resolution: { type: string }
project_duration_sec: { type: number }
scene_count: { type: integer }
last_edit_date: { type: string, format: date-time }
created_at: { type: string, format: date-time }
SavedProject:
allOf:
- $ref: '#/components/schemas/SavedProjectSummary'
- type: object
properties:
frame_rate: { type: integer }
vip_factor: { type: number }
manual_color_picker: { type: boolean }
selected_preset_story_id: { type: string, format: uuid, nullable: true }
audio: { $ref: '#/components/schemas/AudioSettings' }
SavedProjectUpdate:
type: object
properties:
name: { type: string }
type: { type: string, enum: [Draft, Active, Archived, Trash] }
manual_color_picker: { type: boolean }
selected_preset_story_id: { type: string, format: uuid, nullable: true }
last_edit_step: { type: string }
SavedProjectFull:
allOf:
- $ref: '#/components/schemas/SavedProject'
- type: object
properties:
scenes: { type: array, items: { $ref: '#/components/schemas/SavedSceneFull' } }
shared_colors: { type: array, items: { $ref: '#/components/schemas/SavedSharedColor' } }
shared_color_presets: { type: array, items: { $ref: '#/components/schemas/SavedSharedColorPreset' } }
shared_layers: { type: array, items: { $ref: '#/components/schemas/SavedSharedLayer' } }
AudioSettings:
type: object
properties:
music_track_id: { type: string, format: uuid, nullable: true }
music_file_id: { type: string, format: uuid, nullable: true }
music_url: { type: string, nullable: true }
music_duration_sec: { type: number, nullable: true }
music_volume: { type: number, minimum: 0, maximum: 1 }
voiceover_file_id: { type: string, format: uuid, nullable: true }
voiceover_url: { type: string, nullable: true }
voiceover_duration_sec: { type: number, nullable: true }
voiceover_volume: { type: number, minimum: 0, maximum: 1 }
voiceover_recorded_in_browser: { type: boolean }
sfx_enabled: { type: boolean }
sfx_volume: { type: number, minimum: 0, maximum: 1 }
SavedScene:
type: object
properties:
id: { type: integer, format: int64 }
saved_project_id: { type: string, format: uuid }
original_scene_id: { type: string, format: uuid }
key: { type: string }
title: { type: string }
image: { type: string }
demo: { type: string }
scene_type: { type: string }
sort: { type: integer }
scene_length_sec: { type: number }
min_duration_sec: { type: number }
max_duration_sec: { type: number }
overlap_at_end_sec: { type: number }
manual_color_selection: { type: boolean }
SavedSceneFull:
allOf:
- $ref: '#/components/schemas/SavedScene'
- type: object
properties:
contents: { type: array, items: { $ref: '#/components/schemas/SavedSceneContent' } }
colors: { type: array, items: { $ref: '#/components/schemas/SavedSceneColor' } }
color_presets: { type: array, items: { $ref: '#/components/schemas/SavedSceneColorPreset' } }
characters: { type: array, items: { $ref: '#/components/schemas/SavedSceneCharacter' } }
SavedSceneContent:
type: object
properties:
id: { type: integer, format: int64 }
key: { type: string }
type: { type: string }
value: { type: string }
value_file_id: { type: string, format: uuid, nullable: true }
file_url_cached: { type: string, nullable: true }
inserted_file_type: { type: string, nullable: true }
font_face: { type: string, nullable: true }
font_size: { type: integer, nullable: true }
justify: { type: string }
position_in_container: { type: integer }
direction_layer_value: { type: integer }
is_text_box: { type: boolean }
ai_input_type: { type: string, nullable: true }
selected_dp: { type: integer, nullable: true }
repeater_item_key: { type: string, nullable: true }
repeater_index: { type: integer, nullable: true }
sort: { type: integer }
SavedSceneColor:
type: object
properties:
id: { type: integer, format: int64 }
element_key: { type: string }
title: { type: string }
icon: { type: string }
attr_value: { type: string }
value: { type: string }
is_selected: { type: boolean }
sort: { type: integer }
SavedSceneColorPreset:
type: object
properties:
id: { type: integer, format: int64 }
is_selected: { type: boolean }
sort: { type: integer }
items:
type: array
items:
type: object
properties:
element_key: { type: string }
value: { type: string }
sort: { type: integer }
SavedSceneCharacter:
type: object
properties:
id: { type: integer, format: int64 }
key: { type: string, format: uuid }
name: { type: string }
icon: { type: string }
controllers:
type: array
items:
type: object
properties:
name: { type: string }
key: { type: string }
value: { type: string }
sort: { type: integer }
SavedSharedColor:
type: object
properties:
id: { type: integer, format: int64 }
element_key: { type: string }
title: { type: string }
attr_value: { type: string }
value: { type: string }
is_selected: { type: boolean }
sort: { type: integer }
SavedSharedColorPreset:
type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
is_selected: { type: boolean }
sort: { type: integer }
items:
type: array
items:
type: object
properties:
element_key: { type: string }
value: { type: string }
SavedSharedLayer:
type: object
properties:
id: { type: integer, format: int64 }
key: { type: string }
title: { type: string }
type: { type: string }
value: { type: string }
value_file_id: { type: string, format: uuid, nullable: true }
file_url_cached: { type: string, nullable: true }
font_face: { type: string }
font_size: { type: integer }
justify: { type: string }
position_in_container: { type: integer }
direction_layer_value: { type: integer }
is_text_box: { type: boolean }
sort: { type: integer }
SavedProjectSnapshot:
type: object
description: |
Complete payload for JSX generation. Same shape returned for any
choose_mode; render service decides which JSX generator to use.
properties:
saved_project_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
original_project_name: { type: string }
choose_mode: { type: string }
resolution: { type: string }
frame_rate: { type: integer }
project_duration_sec: { type: number }
vip_factor: { type: number }
aep:
type: object
properties:
url: { type: string }
md5: { type: string }
size_bytes: { type: integer, format: int64 }
render_comp: { type: string }
original_width: { type: integer }
original_height: { type: integer }
audio: { $ref: '#/components/schemas/AudioSettings' }
shared_colors:
type: array
items: { $ref: '#/components/schemas/SavedSharedColor' }
shared_layers:
type: array
items: { $ref: '#/components/schemas/SavedSharedLayer' }
scenes:
type: array
items: { $ref: '#/components/schemas/SavedSceneFull' }
@@ -0,0 +1,199 @@
# WebSocket Protocol — Render Progress
Live render progress is pushed to the browser via WebSocket.
## Connection
```
URL: wss://api.flatrender.ir/ws/v1/render/{job_id}?token={jwt}
Headers: Sec-WebSocket-Protocol: flatrender.v1
```
The JWT carries `user_id` + `tenant_id`. The Gateway validates that the
caller owns the `job_id`. Connection is closed with code `4403` if not.
## Server → Client messages
All server messages are JSON with `type` discriminator.
### `hello` — sent on connect
```json
{
"type": "hello",
"job_id": "uuid",
"current_state": {
"step": "Rendering",
"progress": 47,
"current_frame": 470,
"total_frames": 720,
"started_at": "2026-05-27T10:00:00Z"
}
}
```
### `progress` — frequent (max every 500ms)
```json
{
"type": "progress",
"job_id": "uuid",
"step": "Rendering",
"progress": 67,
"current_frame": 482,
"total_frames": 720,
"eta_seconds": 45,
"preview_b64": "data:image/jpeg;base64,/9j/4AAQ...",
"active_nodes": 3,
"message": null
}
```
`preview_b64` is sent at most every 2s (last rendered frame thumbnail,
~5-15 KB).
### `step_change` — pipeline transition
```json
{
"type": "step_change",
"job_id": "uuid",
"from_step": "Rendering",
"to_step": "Validating",
"at": "2026-05-27T10:05:00Z"
}
```
### `frame_repair` — frames being re-rendered
```json
{
"type": "frame_repair",
"job_id": "uuid",
"missing_frames": [347, 348, 521],
"corrupt_frames": [402],
"attempt": 1,
"max_attempts": 3
}
```
### `node_event` — node-related notice
```json
{
"type": "node_event",
"job_id": "uuid",
"event": "node_crashed",
"node_id": "uuid",
"auto_recovered": true,
"message": "AE crashed on node-7, work reassigned"
}
```
### `done` — terminal success
```json
{
"type": "done",
"job_id": "uuid",
"export_id": "uuid",
"output_url": "https://cdn.flatrender.ir/exports/abc.mp4",
"thumbnail_url": "https://cdn.flatrender.ir/exports/abc.jpg",
"duration_sec": 30,
"size_bytes": 14523456,
"compute_seconds": 124
}
```
### `failed` — terminal failure
```json
{
"type": "failed",
"job_id": "uuid",
"failed_at_step": "Rendering",
"error_message": "AE crashed too many times on this template",
"error_code": "AE_REPEATED_CRASH",
"refund_issued": true,
"trace_id": "uuid"
}
```
### `cancelled` — terminal cancellation
```json
{
"type": "cancelled",
"job_id": "uuid",
"cancelled_at": "2026-05-27T10:03:12Z",
"progress_when_cancelled": 42
}
```
### `error` — protocol-level error (kept open)
```json
{
"type": "error",
"code": "RATE_LIMIT",
"message": "Too many messages; slow down"
}
```
### `ping` — keepalive
```json
{ "type": "ping", "t": 1714294800 }
```
Client SHOULD respond with `{"type":"pong","t":1714294800}` within 30s
or the server may close the connection.
## Client → Server messages
### `pong`
```json
{ "type": "pong", "t": 1714294800 }
```
### `subscribe_snapshot` — also get scene snapshot updates over same socket
```json
{ "type": "subscribe_snapshot", "snapshot_id": "uuid" }
```
### `cancel_job` — request cancellation
```json
{ "type": "cancel_job", "job_id": "uuid" }
```
The server replies with a `cancelled` message once accepted.
## Close codes
| Code | Meaning |
|-------|------------------------------------------|
| 1000 | Normal close (job completed/failed) |
| 1001 | Server going away (deploy) |
| 1008 | Policy violation (bad message) |
| 4401 | Unauthorized (bad/expired JWT) |
| 4403 | Forbidden (don't own this job) |
| 4404 | Job not found |
| 4429 | Rate limited |
## Reconnect strategy (client)
- Reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s)
- On reconnect, `hello` carries `current_state` so UI catches up
- WebSocket is best-effort; UI should also poll `GET /v1/renders/{id}`
if it hasn't received `progress` in > 15s
## Rate limits
| Direction | Limit |
|-----------|-------|
| Server → Client `progress` | Max 2 Hz |
| Server → Client total messages | 10 per second |
| Client → Server | 5 per second |