diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3d978fb..c896304 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -66,7 +66,7 @@ func (h *Handlers) HandlePNG(w http.ResponseWriter, r *http.Request) { return } - pngBytes, err := render.RenderPNG(result.GLBBytes, result.Atlas, req.Rotation, req.Background, req.Width, req.Height) + pngBytes, err := render.RenderPNG(result.GLBBytes, result.Atlas, req.Rotation, req.Background, req.Width, req.Height, true) if err != nil { writeError(w, http.StatusInternalServerError, "render failed: "+err.Error()) return @@ -103,7 +103,7 @@ func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) { return } - gifBytes, err := render.RenderGIF(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.Delay, *req.Dithering) + gifBytes, err := render.RenderGIF(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.Delay, *req.Dithering, *req.AutoZoom) if err != nil { writeError(w, http.StatusInternalServerError, "render failed: "+err.Error()) return @@ -140,7 +140,7 @@ func (h *Handlers) HandleMP4(w http.ResponseWriter, r *http.Request) { return } - mp4Bytes, err := render.RenderMP4(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.FPS) + mp4Bytes, err := render.RenderMP4(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.FPS, *req.AutoZoom) if err != nil { writeError(w, http.StatusInternalServerError, "render failed: "+err.Error()) return diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 0aea624..f4a8683 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -238,7 +238,8 @@ const OpenAPISpec = `{ "width": {"type": "integer", "default": 512, "description": "Image width in pixels"}, "height": {"type": "integer", "default": 512, "description": "Image height in pixels"}, "delay": {"type": "integer", "default": 5, "description": "Centiseconds between frames"}, - "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)"}, + "autoZoom": {"type": "boolean", "default": true, "description": "Auto-zoom camera to fit character tightly in frame"} } }, "MP4Request": { @@ -250,7 +251,8 @@ const OpenAPISpec = `{ "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"} + "fps": {"type": "integer", "default": 12, "description": "Frames per second"}, + "autoZoom": {"type": "boolean", "default": true, "description": "Auto-zoom camera to fit character tightly in frame"} } }, "ErrorResponse": { diff --git a/internal/api/types.go b/internal/api/types.go index ca825a3..c7c39ad 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -20,6 +20,7 @@ type GIFRequest struct { Height int `json:"height"` // default 512 Delay int `json:"delay"` // centiseconds between frames, default 5 Dithering *bool `json:"dithering"` // Floyd-Steinberg dithering, default true + AutoZoom *bool `json:"autoZoom"` // auto-zoom to fit character, default true } // MP4Request represents a request to render a character as MP4 video @@ -30,6 +31,7 @@ type MP4Request struct { Width int `json:"width"` // default 512 Height int `json:"height"` // default 512 FPS int `json:"fps"` // frames per second, default 12 + AutoZoom *bool `json:"autoZoom"` // auto-zoom to fit character, default true } // ErrorResponse represents an error returned by the API @@ -71,6 +73,10 @@ func (r *GIFRequest) ApplyDefaults() { defaultDithering := true r.Dithering = &defaultDithering } + if r.AutoZoom == nil { + defaultAutoZoom := true + r.AutoZoom = &defaultAutoZoom + } } // ApplyDefaults fills in default values for MP4Request @@ -90,4 +96,8 @@ func (r *MP4Request) ApplyDefaults() { if r.Background == "" { r.Background = "#FFFFFF" } + if r.AutoZoom == nil { + defaultAutoZoom := true + r.AutoZoom = &defaultAutoZoom + } } diff --git a/internal/render/gif.go b/internal/render/gif.go index 4e2507b..130833e 100644 --- a/internal/render/gif.go +++ b/internal/render/gif.go @@ -14,7 +14,7 @@ import ( ) // RenderGIF renders a GLB model to an animated GIF rotating 360 degrees -func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames, width, height, delay int, dithering bool) ([]byte, error) { +func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames, width, height, delay int, dithering, autoZoom bool) ([]byte, error) { // Parse background color bgColor, err := ParseHexColor(background) if err != nil { @@ -41,7 +41,7 @@ func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames, go func(frameIdx int) { defer wg.Done() rotation := float64(frameIdx) * rotationPerFrame - renderedFrames[frameIdx] = RenderScene(mesh, atlasImage, rotation, width, height, bgColor) + renderedFrames[frameIdx] = RenderScene(mesh, atlasImage, rotation, width, height, bgColor, autoZoom) }(i) } wg.Wait() diff --git a/internal/render/mp4.go b/internal/render/mp4.go index 3b2ef2b..d5b6446 100644 --- a/internal/render/mp4.go +++ b/internal/render/mp4.go @@ -12,7 +12,7 @@ import ( ) // 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) { +func RenderMP4(glbBytes []byte, atlas *texture.Atlas, background string, frames, width, height, fps int, autoZoom bool) ([]byte, error) { // Parse background color bgColor, err := ParseHexColor(background) if err != nil { @@ -47,7 +47,7 @@ func RenderMP4(glbBytes []byte, atlas *texture.Atlas, background string, frames, go func(frameIdx int) { defer wg.Done() rotation := float64(frameIdx) * rotationPerFrame - img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor) + img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor, autoZoom) // Write frame to temp file framePath := filepath.Join(tempDir, fmt.Sprintf("frame_%04d.png", frameIdx)) diff --git a/internal/render/png.go b/internal/render/png.go index 0fde33a..0355c00 100644 --- a/internal/render/png.go +++ b/internal/render/png.go @@ -9,7 +9,7 @@ import ( ) // RenderPNG renders a GLB model to PNG with the given parameters -func RenderPNG(glbBytes []byte, atlas *texture.Atlas, rotation float64, background string, width, height int) ([]byte, error) { +func RenderPNG(glbBytes []byte, atlas *texture.Atlas, rotation float64, background string, width, height int, autoZoom bool) ([]byte, error) { // Parse background color bgColor, err := ParseHexColor(background) if err != nil { @@ -26,7 +26,7 @@ func RenderPNG(glbBytes []byte, atlas *texture.Atlas, rotation float64, backgrou } // Render the scene - img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor) + img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor, autoZoom) // Encode to PNG var buf bytes.Buffer diff --git a/internal/render/software.go b/internal/render/software.go index e04782e..7f3ec7c 100644 --- a/internal/render/software.go +++ b/internal/render/software.go @@ -313,7 +313,7 @@ func quaternionToMatrix(x, y, z, w float64) fauxgl.Matrix { } // RenderScene renders a mesh with the given parameters -func RenderScene(mesh *fauxgl.Mesh, atlasImage image.Image, rotationY float64, width, height int, bgColor color.Color) image.Image { +func RenderScene(mesh *fauxgl.Mesh, atlasImage image.Image, rotationY float64, width, height int, bgColor color.Color, autoZoom bool) image.Image { context := fauxgl.NewContext(width, height) context.Cull = fauxgl.CullNone context.AlphaBlend = false @@ -342,7 +342,11 @@ func RenderScene(mesh *fauxgl.Mesh, atlasImage image.Image, rotationY float64, w far := 100.0 maxDim := math.Max(modelSize.X, math.Max(modelSize.Y, modelSize.Z)) - cameraDistance := maxDim / (2 * math.Tan(fauxgl.Radians(fovy/2))) * 1.5 + multiplier := 1.5 + if autoZoom { + multiplier = 1.25 + } + cameraDistance := maxDim / (2 * math.Tan(fauxgl.Radians(fovy/2))) * multiplier eye := fauxgl.V(modelCenter.X, modelCenter.Y, modelCenter.Z+cameraDistance) center := modelCenter