Text + MXL logo pattern

This commit is contained in:
Dmitry Sergeev
2026-09-13 16:33:50 +03:00
parent 94ff41a124
commit 97dec92b8b
15 changed files with 999 additions and 23 deletions
+151
View File
@@ -0,0 +1,151 @@
// 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"
"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
}
// 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, centered horizontally. 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 int, 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
const topMargin = 48
if topMargin+h > frameH {
return nil, fmt.Errorf("text: box %dpx does not fit frame height %dpx", topMargin+h, frameH)
}
o := &TextOverlay{
frameW: frameW,
x: ((frameW - w) / 2 / 6) * 6,
y: topMargin,
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
}