feat: add /render/mp4 endpoint with FFmpeg encoding

- Add MP4Request type with fps param
- Render frames in parallel, encode via ffmpeg
- Add ffmpeg to Docker image
This commit is contained in:
devilreef 2026-01-23 01:11:53 +06:00
parent 6345eb9821
commit ce511e1a85
6 changed files with 220 additions and 0 deletions

View file

@ -113,6 +113,43 @@ func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) {
w.Write(gifBytes)
}
// HandleMP4 handles POST /render/mp4
func (h *Handlers) HandleMP4(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
defer r.Body.Close()
var req MP4Request
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
req.ApplyDefaults()
if req.Character == nil {
writeError(w, http.StatusBadRequest, "character field is required")
return
}
result, err := h.svc.MergeFromJSON(req.Character)
if err != nil {
writeError(w, http.StatusInternalServerError, "merge failed: "+err.Error())
return
}
mp4Bytes, err := render.RenderMP4(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.FPS)
if err != nil {
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
return
}
w.Header().Set("Content-Type", "video/mp4")
w.Write(mp4Bytes)
}
// HandleHealth handles GET /health
func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

View file

@ -150,6 +150,43 @@ const OpenAPISpec = `{
}
}
}
},
"/render/mp4": {
"post": {
"summary": "Render character as MP4 video",
"description": "Renders a character as an MP4 video rotating 360 degrees.",
"operationId": "renderMP4",
"tags": ["Render"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MP4Request"
}
}
}
},
"responses": {
"200": {
"description": "MP4 video",
"content": {
"video/mp4": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
}
},
"components": {
@ -204,6 +241,18 @@ const OpenAPISpec = `{
"dithering": {"type": "boolean", "default": true, "description": "Enable Floyd-Steinberg dithering (disable for faster rendering)"}
}
},
"MP4Request": {
"type": "object",
"required": ["character"],
"properties": {
"character": {"$ref": "#/components/schemas/CharacterConfig"},
"background": {"type": "string", "default": "#FFFFFF", "description": "Hex color background"},
"frames": {"type": "integer", "default": 36, "description": "Number of frames (36 = 10° per frame)"},
"width": {"type": "integer", "default": 512, "description": "Video width in pixels"},
"height": {"type": "integer", "default": 512, "description": "Video height in pixels"},
"fps": {"type": "integer", "default": 12, "description": "Frames per second"}
}
},
"ErrorResponse": {
"type": "object",
"properties": {

View file

@ -29,6 +29,7 @@ func NewServer(svc *service.MergeService) http.Handler {
r.Post("/render/glb", h.HandleGLB)
r.Post("/render/png", h.HandlePNG)
r.Post("/render/gif", h.HandleGIF)
r.Post("/render/mp4", h.HandleMP4)
return r
}

View file

@ -22,6 +22,16 @@ type GIFRequest struct {
Dithering *bool `json:"dithering"` // Floyd-Steinberg dithering, default true
}
// MP4Request represents a request to render a character as MP4 video
type MP4Request struct {
Character json.RawMessage `json:"character"`
Background string `json:"background"` // hex color "#RRGGBB", default "#FFFFFF"
Frames int `json:"frames"` // default 36 (10° per frame)
Width int `json:"width"` // default 512
Height int `json:"height"` // default 512
FPS int `json:"fps"` // frames per second, default 12
}
// ErrorResponse represents an error returned by the API
type ErrorResponse struct {
Error string `json:"error"`
@ -62,3 +72,22 @@ func (r *GIFRequest) ApplyDefaults() {
r.Dithering = &defaultDithering
}
}
// ApplyDefaults fills in default values for MP4Request
func (r *MP4Request) ApplyDefaults() {
if r.Width == 0 {
r.Width = 512
}
if r.Height == 0 {
r.Height = 512
}
if r.Frames == 0 {
r.Frames = 36
}
if r.FPS == 0 {
r.FPS = 12
}
if r.Background == "" {
r.Background = "#FFFFFF"
}
}