Files
go-mxl-pattern-generator/internal/generator/text.go
T
2026-09-17 20:42:38 +03:00

204 lines
5.9 KiB
Go

// Static text overlay for v210 frames: text is rasterized with real font
// anti-aliasing onto a black backing box, pre-packed into whole v210 blocks
// once, and stamped per frame with a block-aligned copy (microseconds).
package generator
import (
"encoding/binary"
"fmt"
"image"
"os"
"slices"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
type TextOverlay struct {
frameW int
x, y int // box top-left in frame pixels
w, h int // box size in pixels (w is a multiple of 6)
cov []byte // 8-bit anti-alias coverage, row-major w*h
blocks []byte // pre-packed v210 tile: h rows x (w/6) blocks x 16 bytes
}
var TextPosAvailable []string = []string{"tl", "tc", "tr", "cc", "bl", "bc", "br"}
// LoadFace parses a TTF/OTF file and builds a render-ready face at the given
// pixel size (DPI 72, full hinting for crisp video text). Call once and
// reuse for every overlay.
func LoadFace(path string, size float64) (font.Face, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("text: %w", err)
}
face, err := NewFace(data, size)
if err != nil {
return nil, fmt.Errorf("text: load %s: %w", path, err)
}
return face, nil
}
// NewFace parses TTF/OTF data and builds a render-ready face at the given
// pixel size (DPI 72, full hinting for crisp video text).
func NewFace(data []byte, size float64) (font.Face, error) {
f, err := opentype.Parse(data)
if err != nil {
return nil, fmt.Errorf("parse font: %w", err)
}
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("create font face: %w", err)
}
return face, nil
}
// NewTextOverlay rasterizes text (white, anti-aliased) onto a black backing box.
// Box width and x are multiples of 6 so the box
// covers whole v210 blocks and the per-frame copy is word-aligned.
func NewTextOverlay(text string, frameW, frameH, posX, posY int, textPos string, face font.Face) (*TextOverlay, error) {
if text == "" {
return nil, fmt.Errorf("text: empty string")
}
if frameW <= 0 || frameW%6 != 0 {
return nil, fmt.Errorf("text: frame width %d not a multiple of 6", frameW)
}
pix := func(v fixed.Int26_6) int { return int(v+63) / 64 } // ceil to px
m := face.Metrics()
textH := pix(m.Ascent+m.Descent) + 2 // slack for AA/hinting overshoot
// Box width: sum of per-rune advances (uniform for monospace fonts).
spaceAdv, ok := face.GlyphAdvance(' ')
if !ok {
spaceAdv = m.Height
}
var total fixed.Int26_6
for _, r := range text {
a, ok := face.GlyphAdvance(r)
if !ok {
a = spaceAdv
}
total += a
}
textW := pix(total)
padX := pix(m.Ascent) / 3
padY := padX / 2
w := 2*padX + textW
if r := w % 6; r != 0 {
w += 6 - r
}
if w > frameW {
return nil, fmt.Errorf("text: box %dpx wider than frame %dpx", w, frameW)
}
h := 2*padY + textH
if h > frameH {
return nil, fmt.Errorf("text: box %dpx higher than frame %dpx", h, frameH)
}
if textPos != "" {
if !slices.Contains(TextPosAvailable, textPos) {
return nil, fmt.Errorf("text: unknown text position preset '%s'", textPos)
}
switch textPos {
case "tl":
posX = 0
posY = 0
case "tc":
posX = ((frameW - w) / 2 / 6) * 6
posY = 0
case "tr":
posX = frameW - w
posY = 0
case "cc":
posX = ((frameW - w) / 2 / 6) * 6
posY = (frameH - h) / 2
case "bl":
posX = 0
posY = frameH - h
case "bc":
posX = ((frameW - w) / 2 / 6) * 6
posY = frameH - h
case "br":
posX = frameW - w
posY = frameH - h
}
}
if posX < 0 || posY < 0 {
return nil, fmt.Errorf("text: position (%d, %d) must not be negative", posX, posY)
}
if posX%6 != 0 {
return nil, fmt.Errorf("text: x position %d must be divisible by 6 for v210", posX)
}
if posX+w > frameW || posY+h > frameH {
return nil, fmt.Errorf(
"text: box at (%d, %d), size %dx%d, does not fit frame %dx%d",
posX, posY, w, h, frameW, frameH,
)
}
o := &TextOverlay{
frameW: frameW,
x: posX,
y: posY,
w: w,
h: h,
cov: make([]byte, w*h),
}
// Rasterize: drawing white onto *image.Alpha writes pure glyph coverage.
dst := image.NewAlpha(image.Rect(0, 0, textW, textH))
(&font.Drawer{
Dst: dst, Src: image.White, Face: face,
Dot: fixed.P(0, pix(m.Ascent)),
}).DrawString(text)
for row := 0; row < textH; row++ {
copy(o.cov[(padY+row)*w+padX:], dst.Pix[row*dst.Stride:row*dst.Stride+textW])
}
o.pack()
return o, nil
}
// pack pre-renders the box into v210 blocks: white text (Y=940) blended by
// coverage over a black box (Y=64), neutral chroma (512). Same word layout
// as the kernels, chroma co-sited with luma samples 0/2/4.
func (o *TextOverlay) pack() {
const neutral = uint32(512)
o.blocks = make([]byte, o.h*(o.w/6)*16)
for row := 0; row < o.h; row++ {
for blk := 0; blk < o.w/6; blk++ {
var y [6]uint32
for i := 0; i < 6; i++ {
c := int(o.cov[row*o.w+blk*6+i])
y[i] = 64 + uint32((c*876+127)/255)
}
off := (row*(o.w/6) + blk) * 16
binary.LittleEndian.PutUint32(o.blocks[off+0:], neutral|(y[0]<<10)|(neutral<<20))
binary.LittleEndian.PutUint32(o.blocks[off+4:], y[1]|(neutral<<10)|(y[2]<<20))
binary.LittleEndian.PutUint32(o.blocks[off+8:], neutral|(y[3]<<10)|(neutral<<20))
binary.LittleEndian.PutUint32(o.blocks[off+12:], y[4]|(neutral<<10)|(y[5]<<20))
}
}
}
// ApplyV210 stamps the pre-packed text tile over a packed v210 frame.
// The tile occupies whole 16-byte blocks, so this is a row-wise copy.
func (o *TextOverlay) ApplyV210(dest []byte) error {
lastPixel := (o.y+o.h-1)*o.frameW + o.x + o.w - 1
if need := (lastPixel/6 + 1) * 16; len(dest) < need {
return fmt.Errorf("text: dest %d bytes too small, need %d", len(dest), need)
}
tileStride := (o.w / 6) * 16
for row := 0; row < o.h; row++ {
frameBlock := ((o.y+row)*o.frameW + o.x) / 6
src := o.blocks[row*tileStride : (row+1)*tileStride]
copy(dest[frameBlock*16:frameBlock*16+tileStride], src)
}
return nil
}