feat: add autoZoom param to GIF/MP4 renderers
Adjusts camera distance multiplier from 1.5 to 1.25 when enabled, reducing empty space around character while leaving room for cosmetics.
This commit is contained in:
parent
f8947fdcc4
commit
fcc495c06f
7 changed files with 29 additions and 13 deletions
|
|
@ -66,7 +66,7 @@ func (h *Handlers) HandlePNG(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -103,7 +103,7 @@ func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -140,7 +140,7 @@ func (h *Handlers) HandleMP4(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,8 @@ const OpenAPISpec = `{
|
||||||
"width": {"type": "integer", "default": 512, "description": "Image width in pixels"},
|
"width": {"type": "integer", "default": 512, "description": "Image width in pixels"},
|
||||||
"height": {"type": "integer", "default": 512, "description": "Image height in pixels"},
|
"height": {"type": "integer", "default": 512, "description": "Image height in pixels"},
|
||||||
"delay": {"type": "integer", "default": 5, "description": "Centiseconds between frames"},
|
"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": {
|
"MP4Request": {
|
||||||
|
|
@ -250,7 +251,8 @@ const OpenAPISpec = `{
|
||||||
"frames": {"type": "integer", "default": 36, "description": "Number of frames (36 = 10° per frame)"},
|
"frames": {"type": "integer", "default": 36, "description": "Number of frames (36 = 10° per frame)"},
|
||||||
"width": {"type": "integer", "default": 512, "description": "Video width in pixels"},
|
"width": {"type": "integer", "default": 512, "description": "Video width in pixels"},
|
||||||
"height": {"type": "integer", "default": 512, "description": "Video height 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": {
|
"ErrorResponse": {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type GIFRequest struct {
|
||||||
Height int `json:"height"` // default 512
|
Height int `json:"height"` // default 512
|
||||||
Delay int `json:"delay"` // centiseconds between frames, default 5
|
Delay int `json:"delay"` // centiseconds between frames, default 5
|
||||||
Dithering *bool `json:"dithering"` // Floyd-Steinberg dithering, default true
|
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
|
// MP4Request represents a request to render a character as MP4 video
|
||||||
|
|
@ -30,6 +31,7 @@ type MP4Request struct {
|
||||||
Width int `json:"width"` // default 512
|
Width int `json:"width"` // default 512
|
||||||
Height int `json:"height"` // default 512
|
Height int `json:"height"` // default 512
|
||||||
FPS int `json:"fps"` // frames per second, default 12
|
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
|
// ErrorResponse represents an error returned by the API
|
||||||
|
|
@ -71,6 +73,10 @@ func (r *GIFRequest) ApplyDefaults() {
|
||||||
defaultDithering := true
|
defaultDithering := true
|
||||||
r.Dithering = &defaultDithering
|
r.Dithering = &defaultDithering
|
||||||
}
|
}
|
||||||
|
if r.AutoZoom == nil {
|
||||||
|
defaultAutoZoom := true
|
||||||
|
r.AutoZoom = &defaultAutoZoom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults fills in default values for MP4Request
|
// ApplyDefaults fills in default values for MP4Request
|
||||||
|
|
@ -90,4 +96,8 @@ func (r *MP4Request) ApplyDefaults() {
|
||||||
if r.Background == "" {
|
if r.Background == "" {
|
||||||
r.Background = "#FFFFFF"
|
r.Background = "#FFFFFF"
|
||||||
}
|
}
|
||||||
|
if r.AutoZoom == nil {
|
||||||
|
defaultAutoZoom := true
|
||||||
|
r.AutoZoom = &defaultAutoZoom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderGIF renders a GLB model to an animated GIF rotating 360 degrees
|
// 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
|
// Parse background color
|
||||||
bgColor, err := ParseHexColor(background)
|
bgColor, err := ParseHexColor(background)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -41,7 +41,7 @@ func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames,
|
||||||
go func(frameIdx int) {
|
go func(frameIdx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
rotation := float64(frameIdx) * rotationPerFrame
|
rotation := float64(frameIdx) * rotationPerFrame
|
||||||
renderedFrames[frameIdx] = RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
|
renderedFrames[frameIdx] = RenderScene(mesh, atlasImage, rotation, width, height, bgColor, autoZoom)
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderMP4 renders a GLB model to an MP4 video rotating 360 degrees
|
// 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
|
// Parse background color
|
||||||
bgColor, err := ParseHexColor(background)
|
bgColor, err := ParseHexColor(background)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -47,7 +47,7 @@ func RenderMP4(glbBytes []byte, atlas *texture.Atlas, background string, frames,
|
||||||
go func(frameIdx int) {
|
go func(frameIdx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
rotation := float64(frameIdx) * rotationPerFrame
|
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
|
// Write frame to temp file
|
||||||
framePath := filepath.Join(tempDir, fmt.Sprintf("frame_%04d.png", frameIdx))
|
framePath := filepath.Join(tempDir, fmt.Sprintf("frame_%04d.png", frameIdx))
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderPNG renders a GLB model to PNG with the given parameters
|
// 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
|
// Parse background color
|
||||||
bgColor, err := ParseHexColor(background)
|
bgColor, err := ParseHexColor(background)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -26,7 +26,7 @@ func RenderPNG(glbBytes []byte, atlas *texture.Atlas, rotation float64, backgrou
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the scene
|
// Render the scene
|
||||||
img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
|
img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor, autoZoom)
|
||||||
|
|
||||||
// Encode to PNG
|
// Encode to PNG
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,7 @@ func quaternionToMatrix(x, y, z, w float64) fauxgl.Matrix {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderScene renders a mesh with the given parameters
|
// 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 := fauxgl.NewContext(width, height)
|
||||||
context.Cull = fauxgl.CullNone
|
context.Cull = fauxgl.CullNone
|
||||||
context.AlphaBlend = false
|
context.AlphaBlend = false
|
||||||
|
|
@ -342,7 +342,11 @@ func RenderScene(mesh *fauxgl.Mesh, atlasImage image.Image, rotationY float64, w
|
||||||
far := 100.0
|
far := 100.0
|
||||||
|
|
||||||
maxDim := math.Max(modelSize.X, math.Max(modelSize.Y, modelSize.Z))
|
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)
|
eye := fauxgl.V(modelCenter.X, modelCenter.Y, modelCenter.Z+cameraDistance)
|
||||||
center := modelCenter
|
center := modelCenter
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue