194 lines
5.6 KiB
Go
194 lines
5.6 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)
|
|
}
|
|
f, err := opentype.Parse(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("text: parse %s: %w", path, err)
|
|
}
|
|
face, err := opentype.NewFace(f, &opentype.FaceOptions{
|
|
Size: size,
|
|
DPI: 72,
|
|
Hinting: font.HintingFull,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("text: face %s: %w", path, 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
|
|
}
|