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:
parent
6345eb9821
commit
ce511e1a85
6 changed files with 220 additions and 0 deletions
|
|
@ -10,6 +10,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o blockyserver .
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/blockyserver .
|
COPY --from=builder /app/blockyserver .
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,43 @@ func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Write(gifBytes)
|
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
|
// HandleHealth handles GET /health
|
||||||
func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"components": {
|
||||||
|
|
@ -204,6 +241,18 @@ const OpenAPISpec = `{
|
||||||
"dithering": {"type": "boolean", "default": true, "description": "Enable Floyd-Steinberg dithering (disable for faster rendering)"}
|
"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": {
|
"ErrorResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ func NewServer(svc *service.MergeService) http.Handler {
|
||||||
r.Post("/render/glb", h.HandleGLB)
|
r.Post("/render/glb", h.HandleGLB)
|
||||||
r.Post("/render/png", h.HandlePNG)
|
r.Post("/render/png", h.HandlePNG)
|
||||||
r.Post("/render/gif", h.HandleGIF)
|
r.Post("/render/gif", h.HandleGIF)
|
||||||
|
r.Post("/render/mp4", h.HandleMP4)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,16 @@ type GIFRequest struct {
|
||||||
Dithering *bool `json:"dithering"` // Floyd-Steinberg dithering, default true
|
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
|
// ErrorResponse represents an error returned by the API
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
|
@ -62,3 +72,22 @@ func (r *GIFRequest) ApplyDefaults() {
|
||||||
r.Dithering = &defaultDithering
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
102
internal/render/mp4.go
Normal file
102
internal/render/mp4.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue