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:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user