From ce511e1a85c545956e760638a79cbd3ddaf21f88 Mon Sep 17 00:00:00 2001 From: devilreef <86633411+devilr33f@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:11:53 +0600 Subject: [PATCH] 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 --- Dockerfile | 2 + internal/api/handlers.go | 37 ++++++++++++++ internal/api/openapi.go | 49 +++++++++++++++++++ internal/api/server.go | 1 + internal/api/types.go | 29 +++++++++++ internal/render/mp4.go | 102 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+) create mode 100644 internal/render/mp4.go diff --git a/Dockerfile b/Dockerfile index 3b46cf7..9c29e51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o blockyserver . FROM alpine:latest +RUN apk add --no-cache ffmpeg + WORKDIR /app COPY --from=builder /app/blockyserver . diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 2a93437..3d978fb 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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") diff --git a/internal/api/openapi.go b/internal/api/openapi.go index dc681f5..0aea624 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -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": { diff --git a/internal/api/server.go b/internal/api/server.go index 68b9ef7..d0efee5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 } diff --git a/internal/api/types.go b/internal/api/types.go index 14300a3..ca825a3 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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" + } +} diff --git a/internal/render/mp4.go b/internal/render/mp4.go new file mode 100644 index 0000000..3b2ef2b --- /dev/null +++ b/internal/render/mp4.go @@ -0,0 +1,102 @@ +package render + +import ( + "fmt" + "image/png" + "os" + "os/exec" + "path/filepath" + "sync" + + "github.com/hytale-tools/blockymodel-merger/pkg/texture" +) + +// RenderMP4 renders a GLB model to an MP4 video rotating 360 degrees +func RenderMP4(glbBytes []byte, atlas *texture.Atlas, background string, frames, width, height, fps int) ([]byte, error) { + // Parse background color + bgColor, err := ParseHexColor(background) + if err != nil { + return nil, fmt.Errorf("invalid background color: %w", err) + } + + // Get atlas image + var atlasImage = atlas.Image + + // Convert GLB to mesh + mesh, err := GLBToMesh(glbBytes, atlasImage) + if err != nil { + return nil, fmt.Errorf("converting GLB to mesh: %w", err) + } + + // Create temp directory for frames + tempDir, err := os.MkdirTemp("", "blockyserver-mp4-*") + if err != nil { + return nil, fmt.Errorf("creating temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Calculate rotation per frame + rotationPerFrame := 360.0 / float64(frames) + + // Render all frames in parallel + var wg sync.WaitGroup + errChan := make(chan error, frames) + + for i := 0; i < frames; i++ { + wg.Add(1) + go func(frameIdx int) { + defer wg.Done() + rotation := float64(frameIdx) * rotationPerFrame + img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor) + + // Write frame to temp file + framePath := filepath.Join(tempDir, fmt.Sprintf("frame_%04d.png", frameIdx)) + f, err := os.Create(framePath) + if err != nil { + errChan <- fmt.Errorf("creating frame file: %w", err) + return + } + defer f.Close() + + if err := png.Encode(f, img); err != nil { + errChan <- fmt.Errorf("encoding frame PNG: %w", err) + return + } + }(i) + } + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + return nil, err + } + } + + // Run FFmpeg to encode MP4 + outputPath := filepath.Join(tempDir, "output.mp4") + inputPattern := filepath.Join(tempDir, "frame_%04d.png") + + cmd := exec.Command("ffmpeg", + "-y", + "-framerate", fmt.Sprintf("%d", fps), + "-i", inputPattern, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + outputPath, + ) + + if output, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("ffmpeg encoding failed: %w\nOutput: %s", err, string(output)) + } + + // Read output file + mp4Bytes, err := os.ReadFile(outputPath) + if err != nil { + return nil, fmt.Errorf("reading output MP4: %w", err) + } + + return mp4Bytes, nil +}