Text + MXL logo pattern
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user