// 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 const topMargin = 48 if topMargin+h > frameH { return nil, fmt.Errorf("text: box %dpx does not fit frame height %dpx", topMargin+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 } } 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 }