Skip to content

Commit ce511e1

Browse files
committed
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
1 parent 6345eb9 commit ce511e1

6 files changed

Lines changed: 220 additions & 0 deletions

File tree

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o blockyserver .
1010

1111
FROM alpine:latest
1212

13+
RUN apk add --no-cache ffmpeg
14+
1315
WORKDIR /app
1416

1517
COPY --from=builder /app/blockyserver .

internal/api/handlers.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,43 @@ func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) {
113113
w.Write(gifBytes)
114114
}
115115

116+
// HandleMP4 handles POST /render/mp4
117+
func (h *Handlers) HandleMP4(w http.ResponseWriter, r *http.Request) {
118+
body, err := io.ReadAll(r.Body)
119+
if err != nil {
120+
writeError(w, http.StatusBadRequest, "failed to read request body")
121+
return
122+
}
123+
defer r.Body.Close()
124+
125+
var req MP4Request
126+
if err := json.Unmarshal(body, &req); err != nil {
127+
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
128+
return
129+
}
130+
req.ApplyDefaults()
131+
132+
if req.Character == nil {
133+
writeError(w, http.StatusBadRequest, "character field is required")
134+
return
135+
}
136+
137+
result, err := h.svc.MergeFromJSON(req.Character)
138+
if err != nil {
139+
writeError(w, http.StatusInternalServerError, "merge failed: "+err.Error())
140+
return
141+
}
142+
143+
mp4Bytes, err := render.RenderMP4(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.FPS)
144+
if err != nil {
145+
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
146+
return
147+
}
148+
149+
w.Header().Set("Content-Type", "video/mp4")
150+
w.Write(mp4Bytes)
151+
}
152+
116153
// HandleHealth handles GET /health
117154
func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
118155
w.Header().Set("Content-Type", "application/json")

internal/api/openapi.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,43 @@ const OpenAPISpec = `{
150150
}
151151
}
152152
}
153+
},
154+
"/render/mp4": {
155+
"post": {
156+
"summary": "Render character as MP4 video",
157+
"description": "Renders a character as an MP4 video rotating 360 degrees.",
158+
"operationId": "renderMP4",
159+
"tags": ["Render"],
160+
"requestBody": {
161+
"required": true,
162+
"content": {
163+
"application/json": {
164+
"schema": {
165+
"$ref": "#/components/schemas/MP4Request"
166+
}
167+
}
168+
}
169+
},
170+
"responses": {
171+
"200": {
172+
"description": "MP4 video",
173+
"content": {
174+
"video/mp4": {
175+
"schema": {
176+
"type": "string",
177+
"format": "binary"
178+
}
179+
}
180+
}
181+
},
182+
"400": {
183+
"$ref": "#/components/responses/BadRequest"
184+
},
185+
"500": {
186+
"$ref": "#/components/responses/InternalError"
187+
}
188+
}
189+
}
153190
}
154191
},
155192
"components": {
@@ -204,6 +241,18 @@ const OpenAPISpec = `{
204241
"dithering": {"type": "boolean", "default": true, "description": "Enable Floyd-Steinberg dithering (disable for faster rendering)"}
205242
}
206243
},
244+
"MP4Request": {
245+
"type": "object",
246+
"required": ["character"],
247+
"properties": {
248+
"character": {"$ref": "#/components/schemas/CharacterConfig"},
249+
"background": {"type": "string", "default": "#FFFFFF", "description": "Hex color background"},
250+
"frames": {"type": "integer", "default": 36, "description": "Number of frames (36 = 10° per frame)"},
251+
"width": {"type": "integer", "default": 512, "description": "Video width in pixels"},
252+
"height": {"type": "integer", "default": 512, "description": "Video height in pixels"},
253+
"fps": {"type": "integer", "default": 12, "description": "Frames per second"}
254+
}
255+
},
207256
"ErrorResponse": {
208257
"type": "object",
209258
"properties": {

internal/api/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func NewServer(svc *service.MergeService) http.Handler {
2929
r.Post("/render/glb", h.HandleGLB)
3030
r.Post("/render/png", h.HandlePNG)
3131
r.Post("/render/gif", h.HandleGIF)
32+
r.Post("/render/mp4", h.HandleMP4)
3233

3334
return r
3435
}

internal/api/types.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ type GIFRequest struct {
2222
Dithering *bool `json:"dithering"` // Floyd-Steinberg dithering, default true
2323
}
2424

25+
// MP4Request represents a request to render a character as MP4 video
26+
type MP4Request struct {
27+
Character json.RawMessage `json:"character"`
28+
Background string `json:"background"` // hex color "#RRGGBB", default "#FFFFFF"
29+
Frames int `json:"frames"` // default 36 (10° per frame)
30+
Width int `json:"width"` // default 512
31+
Height int `json:"height"` // default 512
32+
FPS int `json:"fps"` // frames per second, default 12
33+
}
34+
2535
// ErrorResponse represents an error returned by the API
2636
type ErrorResponse struct {
2737
Error string `json:"error"`
@@ -62,3 +72,22 @@ func (r *GIFRequest) ApplyDefaults() {
6272
r.Dithering = &defaultDithering
6373
}
6474
}
75+
76+
// ApplyDefaults fills in default values for MP4Request
77+
func (r *MP4Request) ApplyDefaults() {
78+
if r.Width == 0 {
79+
r.Width = 512
80+
}
81+
if r.Height == 0 {
82+
r.Height = 512
83+
}
84+
if r.Frames == 0 {
85+
r.Frames = 36
86+
}
87+
if r.FPS == 0 {
88+
r.FPS = 12
89+
}
90+
if r.Background == "" {
91+
r.Background = "#FFFFFF"
92+
}
93+
}

internal/render/mp4.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package render
2+
3+
import (
4+
"fmt"
5+
"image/png"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"sync"
10+
11+
"github.com/hytale-tools/blockymodel-merger/pkg/texture"
12+
)
13+
14+
// RenderMP4 renders a GLB model to an MP4 video rotating 360 degrees
15+
func RenderMP4(glbBytes []byte, atlas *texture.Atlas, background string, frames, width, height, fps int) ([]byte, error) {
16+
// Parse background color
17+
bgColor, err := ParseHexColor(background)
18+
if err != nil {
19+
return nil, fmt.Errorf("invalid background color: %w", err)
20+
}
21+
22+
// Get atlas image
23+
var atlasImage = atlas.Image
24+
25+
// Convert GLB to mesh
26+
mesh, err := GLBToMesh(glbBytes, atlasImage)
27+
if err != nil {
28+
return nil, fmt.Errorf("converting GLB to mesh: %w", err)
29+
}
30+
31+
// Create temp directory for frames
32+
tempDir, err := os.MkdirTemp("", "blockyserver-mp4-*")
33+
if err != nil {
34+
return nil, fmt.Errorf("creating temp directory: %w", err)
35+
}
36+
defer os.RemoveAll(tempDir)
37+
38+
// Calculate rotation per frame
39+
rotationPerFrame := 360.0 / float64(frames)
40+
41+
// Render all frames in parallel
42+
var wg sync.WaitGroup
43+
errChan := make(chan error, frames)
44+
45+
for i := 0; i < frames; i++ {
46+
wg.Add(1)
47+
go func(frameIdx int) {
48+
defer wg.Done()
49+
rotation := float64(frameIdx) * rotationPerFrame
50+
img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
51+
52+
// Write frame to temp file
53+
framePath := filepath.Join(tempDir, fmt.Sprintf("frame_%04d.png", frameIdx))
54+
f, err := os.Create(framePath)
55+
if err != nil {
56+
errChan <- fmt.Errorf("creating frame file: %w", err)
57+
return
58+
}
59+
defer f.Close()
60+
61+
if err := png.Encode(f, img); err != nil {
62+
errChan <- fmt.Errorf("encoding frame PNG: %w", err)
63+
return
64+
}
65+
}(i)
66+
}
67+
wg.Wait()
68+
close(errChan)
69+
70+
// Check for errors
71+
for err := range errChan {
72+
if err != nil {
73+
return nil, err
74+
}
75+
}
76+
77+
// Run FFmpeg to encode MP4
78+
outputPath := filepath.Join(tempDir, "output.mp4")
79+
inputPattern := filepath.Join(tempDir, "frame_%04d.png")
80+
81+
cmd := exec.Command("ffmpeg",
82+
"-y",
83+
"-framerate", fmt.Sprintf("%d", fps),
84+
"-i", inputPattern,
85+
"-c:v", "libx264",
86+
"-pix_fmt", "yuv420p",
87+
"-movflags", "+faststart",
88+
outputPath,
89+
)
90+
91+
if output, err := cmd.CombinedOutput(); err != nil {
92+
return nil, fmt.Errorf("ffmpeg encoding failed: %w\nOutput: %s", err, string(output))
93+
}
94+
95+
// Read output file
96+
mp4Bytes, err := os.ReadFile(outputPath)
97+
if err != nil {
98+
return nil, fmt.Errorf("reading output MP4: %w", err)
99+
}
100+
101+
return mp4Bytes, nil
102+
}

0 commit comments

Comments
 (0)