Compare commits

...
7 Commits
Author SHA1 Message Date
Jean-André Santoni 720bdfe58a Tweaks 2026-01-06 21:33:07 +01:00
Jean-André Santoni 04c481906b Tweaks 2026-01-06 18:00:15 +01:00
Jean-André Santoni 518dc203a5 Make it work on 1 game 2026-01-06 17:28:06 +01:00
Jean-André Santoni 5352ab2bca Display kanjis 2026-01-05 20:28:09 +01:00
Jean-André Santoni ea3873befa Options 2025-08-10 23:51:38 +07:00
Jean-André Santoni 65c7f62eb0 Try to display more stuff 2025-08-10 23:41:01 +07:00
Jean-André Santoni 9cc51e50ff UTF-8 2025-08-10 22:58:32 +07:00
15 changed files with 512 additions and 129 deletions

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mholt/archiver/v3 v3.5.1
github.com/otiai10/gosseract/v2 v2.4.1
github.com/pelletier/go-toml v1.9.5
github.com/tanema/gween v0.0.0-20250522035225-e874ee3ae01a
github.com/youpy/go-wav v0.3.2
+2
View File
@@ -43,6 +43,8 @@ github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssn
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw=
github.com/otiai10/gosseract/v2 v2.4.1/go.mod h1:1gNWP4Hgr2o7yqWfs6r5bZxAatjOIdqWxJLWsTsembk=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+1
View File
@@ -21,5 +21,6 @@ var keyBinds = map[glfw.Key]uint32{
glfw.KeySpace: ActionFastForwardToggle,
glfw.KeyP: ActionMenuToggle,
glfw.KeyF: ActionFullscreenToggle,
glfw.KeyT: ActionOCRSubtitle,
glfw.KeyEscape: ActionShouldClose,
}
+3 -1
View File
@@ -45,8 +45,10 @@ const (
ActionShouldClose uint32 = lr.DeviceIDJoypadR3 + 3
// ActionFastForwardToggle will run the core as fast as possible
ActionFastForwardToggle uint32 = lr.DeviceIDJoypadR3 + 4
// ActionOCRSubtitle will capture the current frame, run OCR, and display it as a subtitle
ActionOCRSubtitle uint32 = lr.DeviceIDJoypadR3 + 5
// ActionLast is used for iterating
ActionLast uint32 = lr.DeviceIDJoypadR3 + 5
ActionLast uint32 = lr.DeviceIDJoypadR3 + 6
)
// joystickCallback is triggered when a joypad is plugged.
+1
View File
@@ -62,6 +62,7 @@ func runLoop(vid *video.Video, m *menu.Menu) {
vid.Render()
m.Render(dt)
}
vid.RenderSubtitle()
m.RenderNotifications()
if state.FastForward {
glfw.SwapInterval(0)
+24
View File
@@ -1,6 +1,9 @@
package menu
import (
"log"
"time"
"github.com/libretro/ludo/audio"
"github.com/libretro/ludo/input"
"github.com/libretro/ludo/libretro"
@@ -213,6 +216,27 @@ func (m *Menu) ProcessHotkeys() {
}
}
if input.Pressed[0][input.ActionOCRSubtitle] == 1 && state.CoreRunning && !state.MenuActive {
text, err := m.Video.OCRCurrentFrame()
if err != nil {
ntf.DisplayAndLog(ntf.Error, "OCR", "%v", err)
} else {
m.Video.SetSubtitle(text, 8*time.Second)
if text != "" {
log.Printf("[OCR] %s\n", text)
}
preview := []rune(text)
if len(preview) > 80 {
preview = append(preview[:80], '…')
}
if len(preview) == 0 {
ntf.DisplayAndLog(ntf.Info, "OCR", "(no text detected)")
} else {
ntf.DisplayAndLog(ntf.Info, "OCR", "%s", string(preview))
}
}
}
// Close if ActionShouldClose is pressed, but display a confirmation dialog
// in case a game is running
if input.Pressed[0][input.ActionShouldClose] == 1 {
+2 -2
View File
@@ -28,11 +28,11 @@ func buildCoreOptions() Scene {
for _, v := range core.Options.Vars {
v := v
list.children = append(list.children, entry{
label: strings.Replace(v.Desc, "%", "%%", -1),
label: strings.ReplaceAll(v.Desc, "%", "%%"),
icon: "subsetting",
stringValue: func() string {
val := v.Choices[v.Choice]
return strings.Replace(val, "%", "%%", -1)
return "◀ " + strings.ReplaceAll(val, "%", "%%") + " ▶"
},
incr: func(direction int) {
v.Choice += direction
+1 -1
View File
@@ -60,7 +60,7 @@ func buildSettings() Scene {
icon: "folder",
value: f.Value,
stringValue: func() string {
return "[" + utils.FileName(f.Value().(string)) + "]"
return "【" + utils.FileName(f.Value().(string)) + "】"
},
widget: widgets[f.Tag("widget")],
callbackOK: func() {
+121
View File
@@ -0,0 +1,121 @@
package ocr
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/disintegration/imaging"
"github.com/otiai10/gosseract/v2"
)
var (
clientMu sync.Mutex
client *gosseract.Client
)
func initClient() *gosseract.Client {
if client != nil {
return client
}
client = gosseract.NewClient()
client.SetLanguage("jpn")
return client
}
// TextFromImage runs OCR on an image and returns the detected text.
func TextFromImage(img image.Image) (string, error) {
processed := preprocess(img)
path := filepath.Join(fmt.Sprintf("ludo-ocr-%d.png", time.Now().UnixNano()))
if err := saveDebugImage(path, processed); err != nil {
log.Printf("[OCR] failed to save debug image: %v", err)
} else {
log.Printf("[OCR] saved debug image: %s", path)
}
buf := bytes.Buffer{}
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&buf, processed); err != nil {
return "", err
}
clientMu.Lock()
defer clientMu.Unlock()
c := initClient()
if err := c.SetImageFromBytes(buf.Bytes()); err != nil {
return "", err
}
// Try a focused page segmentation first, then broaden if empty.
c.SetPageSegMode(gosseract.PSM_SINGLE_BLOCK)
text, err := c.Text()
if err == nil {
text = strings.TrimSpace(text)
}
if err == nil && text == "" {
c.SetPageSegMode(gosseract.PSM_AUTO)
text, err = c.Text()
if err == nil {
text = strings.TrimSpace(text)
}
}
return text, err
}
func preprocess(img image.Image) image.Image {
b := img.Bounds()
w := b.Dx()
h := b.Dy()
// Binarize to keep only the observed text color (#cbc6dd).
const (
targetR = 0xcb
targetG = 0xc6
targetB = 0xdd
)
bin := image.NewGray(img.Bounds())
for y := bin.Bounds().Min.Y; y < bin.Bounds().Max.Y; y++ {
for x := bin.Bounds().Min.X; x < bin.Bounds().Max.X; x++ {
r, g, b, _ := img.At(x, y).RGBA()
rr := int(r >> 8)
gg := int(g >> 8)
bb := int(b >> 8)
if rr == targetR && gg == targetG && bb == targetB {
bin.SetGray(x, y, color.Gray{Y: 255})
} else {
bin.SetGray(x, y, color.Gray{Y: 0})
}
}
}
// Upscale the frame to help OCR decode small text.
scale := 6
resized := imaging.Resize(bin, w*scale, h*scale, imaging.Linear)
return resized
}
func saveDebugImage(path string, img image.Image) error {
fd, err := os.Create(path)
if err != nil {
return err
}
defer fd.Close()
return png.Encode(fd, img)
}
+3 -3
View File
@@ -22,10 +22,10 @@ import (
// Widget sets the graphical representation of the value.
type Settings struct {
VideoFullscreen bool `hide:"ludos" toml:"video_fullscreen" label:"Video Fullscreen" fmt:"%t" widget:"switch"`
VideoMonitorIndex int `toml:"video_monitor_index" label:"Video Monitor Index" fmt:"%d"`
VideoFilter string `toml:"video_filter" label:"Video Filter" fmt:"<%s>"`
VideoMonitorIndex int `toml:"video_monitor_index" label:"Video Monitor Index" fmt:"◀ %d ▶"`
VideoFilter string `toml:"video_filter" label:"Video Filter" fmt:"◀ %s ▶"`
VideoDarkMode bool `toml:"video_dark_mode" label:"Video Dark Mode" fmt:"%t" widget:"switch"`
VideoTheme string `toml:"video_theme" label:"Video Theme" fmt:"<%s>"`
VideoTheme string `toml:"video_theme" label:"Video Theme" fmt:"◀ %s ▶"`
AudioVolume float32 `toml:"audio_volume" label:"Audio Volume" fmt:"%.1f" widget:"range"`
+208 -107
View File
@@ -14,7 +14,7 @@ import (
"golang.org/x/image/math/fixed"
)
type character struct {
type glyph struct {
x, y int
width int // Glyph width
height int // Glyph height
@@ -35,7 +35,7 @@ const (
// A Font allows rendering of text to an OpenGL context.
type Font struct {
fontChar []*character
glyphs map[rune]*glyph
vao uint32
vbo uint32
program uint32
@@ -47,6 +47,15 @@ type Font struct {
type point [4]float32
type glyphMetrics struct {
bounds fixed.Rectangle26_6
advance fixed.Int26_6
width int
height int
ascent int
descent int
}
func max(a, b float32) float32 {
if a > b {
return a
@@ -54,8 +63,172 @@ func max(a, b float32) float32 {
return b
}
func appendRange(runes []rune, start, end rune) []rune {
for r := start; r <= end; r++ {
runes = append(runes, r)
}
return runes
}
func defaultRunes() []rune {
runes := make([]rune, 0, 22000)
runes = appendRange(runes, 32, 126)
runes = append(runes, '◀', '▶', '【', '】')
runes = appendRange(runes, 0x00A0, 0x017F)
runes = appendRange(runes, 0x3040, 0x30FF)
runes = appendRange(runes, 0x4E00, 0x9FAF)
return runes
}
func getGlyphMetrics(face font.Face, ttf *truetype.Font, ch rune, scale int32) (glyphMetrics, error) {
gBnd, gAdv, ok := face.GlyphBounds(ch)
if !ok {
return glyphMetrics{}, fmt.Errorf("ttf face glyphBounds error")
}
gh := int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
gw := int32((gBnd.Max.X - gBnd.Min.X) >> 6)
// If gylph has no dimensions set to a max value
if gw == 0 || gh == 0 {
gBnd = ttf.Bounds(fixed.Int26_6(scale))
gw = int32((gBnd.Max.X - gBnd.Min.X) >> 6)
gh = int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
// Above can sometimes yield 0 for font smaller than 48pt, 1 is minimum
if gw == 0 || gh == 0 {
gw = 1
gh = 1
}
}
// The glyph's ascent and descent equal -bounds.Min.Y and +bounds.Max.Y.
gAscent := int(-gBnd.Min.Y) >> 6
gdescent := int(gBnd.Max.Y) >> 6
return glyphMetrics{
bounds: gBnd,
advance: gAdv,
width: int(gw),
height: int(gh),
ascent: gAscent,
descent: gdescent,
}, nil
}
func increaseLineHeight(face font.Face, ttf *truetype.Font, ch rune, lineHeight float32, scale int32) float32 {
metrics, err := getGlyphMetrics(face, ttf, ch, scale)
if err != nil {
fmt.Println("ttf face glyphBounds error")
return lineHeight
}
return max(lineHeight, float32(metrics.height))
}
func appendGlyph(face font.Face, ttf *truetype.Font, ch rune, x, y *int, lineHeight, atlasWidth float32, atlas *image.RGBA, fg *image.Uniform, scale int32, margin int) (*glyph, error) {
char := new(glyph)
metrics, err := getGlyphMetrics(face, ttf, ch, scale)
if err != nil {
return nil, err
}
// Set w,h and adv, bearing V and bearing H in char
char.x = *x
char.y = *y
char.width = metrics.width
char.height = metrics.height
char.advance = int(metrics.advance)
char.bearingV = metrics.descent
char.bearingH = (int(metrics.bounds.Min.X) >> 6)
clip := image.Rect(*x, *y, *x+metrics.width, *y+metrics.height)
// Create a freetype context for drawing
c := freetype.NewContext()
c.SetDPI(72)
c.SetFont(ttf)
c.SetFontSize(float64(scale))
c.SetClip(clip)
c.SetDst(atlas)
c.SetSrc(fg)
c.SetHinting(font.HintingFull)
// Set the glyph dot
px := 0 - (int(metrics.bounds.Min.X) >> 6) + *x
py := (metrics.ascent) + *y
pt := freetype.Pt(px, py)
*x += metrics.width + margin
if *x+metrics.width+margin > int(atlasWidth) {
*x = 0
*y += int(lineHeight) + margin
}
// Draw the text from mask to image
_, err = c.DrawString(string(ch), pt)
return char, err
}
func calculateAtlasSize(face font.Face, ttf *truetype.Font, runes []rune, scale int32, margin int) (float32, float32, float32, error) {
var maxTextureSize int32
gl.GetIntegerv(gl.MAX_TEXTURE_SIZE, &maxTextureSize)
if maxTextureSize == 0 {
maxTextureSize = 4096
}
var lineHeight float32
for _, r := range runes {
lineHeight = increaseLineHeight(face, ttf, r, lineHeight, scale)
}
width := int(maxTextureSize)
if width > 4096 {
width = 4096
}
for {
x := margin
y := margin
maxY := 0
for _, r := range runes {
metrics, err := getGlyphMetrics(face, ttf, r, scale)
if err != nil {
continue
}
if x+metrics.width+margin > width {
x = margin
y += int(lineHeight) + margin
}
x += metrics.width + margin
if y+int(lineHeight) > maxY {
maxY = y + int(lineHeight)
}
}
height := maxY + margin
if height <= int(maxTextureSize) || width >= int(maxTextureSize) {
if height > int(maxTextureSize) {
return 0, 0, 0, fmt.Errorf("glyph atlas %dx%d exceeds max texture size %d", width, height, maxTextureSize)
}
return float32(width), float32(height), lineHeight, nil
}
width *= 2
if width > int(maxTextureSize) {
width = int(maxTextureSize)
}
}
}
// LoadTrueTypeFont builds a set of textures based on a ttf files gylphs
func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, low, high rune, dir Direction) (*Font, error) {
func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, dir Direction) (*Font, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
@@ -69,108 +242,42 @@ func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, low, high rune,
// Make Font stuct type
f := new(Font)
f.fontChar = make([]*character, 0, high-low+1)
f.glyphs = make(map[rune]*glyph)
f.program = program // Set shader program
f.SetColor(Color{R: 1, G: 1, B: 1, A: 1}) // Set default white
// Create new face
ttfFace := truetype.NewFace(ttf, &truetype.Options{
face := truetype.NewFace(ttf, &truetype.Options{
Size: float64(scale),
DPI: 72,
Hinting: font.HintingFull,
})
margin := 2
runes := defaultRunes()
var lineHeight float32
f.atlasWidth = 1024
f.atlasHeight = 1024
for ch := low; ch <= high; ch++ {
gBnd, _, ok := ttfFace.GlyphBounds(ch)
if !ok {
return nil, fmt.Errorf("ttf face glyphBounds error")
}
gh := int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
lineHeight = max(lineHeight, float32(gh))
f.atlasWidth, f.atlasHeight, lineHeight, err = calculateAtlasSize(face, ttf, runes, scale, margin)
if err != nil {
return nil, err
}
// Create image to draw glyph
fg, bg := image.White, image.Black
rect := image.Rect(0, 0, int(f.atlasWidth), int(f.atlasHeight))
rgba := image.NewRGBA(rect)
draw.Draw(rgba, rgba.Bounds(), bg, image.Point{}, draw.Src)
atlas := image.NewRGBA(rect)
draw.Draw(atlas, atlas.Bounds(), bg, image.Point{}, draw.Src)
margin := 4
x := margin
y := margin
// Make each gylph
for ch := low; ch <= high; ch++ {
char := new(character)
gBnd, gAdv, ok := ttfFace.GlyphBounds(ch)
if !ok {
return nil, fmt.Errorf("ttf face glyphBounds error")
}
gh := int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
gw := int32((gBnd.Max.X - gBnd.Min.X) >> 6)
// If gylph has no dimensions set to a max value
if gw == 0 || gh == 0 {
gBnd = ttf.Bounds(fixed.Int26_6(scale))
gw = int32((gBnd.Max.X - gBnd.Min.X) >> 6)
gh = int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
// Above can sometimes yield 0 for font smaller than 48pt, 1 is minimum
if gw == 0 || gh == 0 {
gw = 1
gh = 1
}
}
// The glyph's ascent and descent equal -bounds.Min.Y and +bounds.Max.Y.
gAscent := int(-gBnd.Min.Y) >> 6
gdescent := int(gBnd.Max.Y) >> 6
// Set w,h and adv, bearing V and bearing H in char
char.x = x
char.y = y
char.width = int(gw)
char.height = int(gh)
char.advance = int(gAdv)
char.bearingV = gdescent
char.bearingH = (int(gBnd.Min.X) >> 6)
clip := image.Rect(x, y, x+int(gw), y+int(gh))
// Create a freetype context for drawing
c := freetype.NewContext()
c.SetDPI(72)
c.SetFont(ttf)
c.SetFontSize(float64(scale))
c.SetClip(clip)
c.SetDst(rgba)
c.SetSrc(fg)
c.SetHinting(font.HintingFull)
// Set the glyph dot
px := 0 - (int(gBnd.Min.X) >> 6) + x
py := (gAscent) + y
pt := freetype.Pt(px, py)
x += int(gw) + margin
if x+int(gw)+margin > int(f.atlasWidth) {
x = 0
y += int(lineHeight) + margin
}
// Draw the text from mask to image
_, err = c.DrawString(string(ch), pt)
for _, r := range runes {
glyph, err := appendGlyph(face, ttf, r, &x, &y, lineHeight, f.atlasWidth, atlas, fg, scale, margin)
if err != nil {
return nil, err
fmt.Printf("error appending glyph %c: %v\n", r, err)
continue
}
// Add char to fontChar list
f.fontChar = append(f.fontChar, char)
f.glyphs[r] = glyph
}
// Generate texture
@@ -179,8 +286,10 @@ func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, low, high rune,
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, int32(rgba.Rect.Dx()), int32(rgba.Rect.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(rgba.Pix))
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, int32(atlas.Rect.Dx()), int32(atlas.Rect.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(atlas.Pix))
gl.GenerateMipmap(gl.TEXTURE_2D)
gl.BindTexture(gl.TEXTURE_2D, 0)
@@ -226,7 +335,7 @@ func LoadFont(file string, scale int32, windowWidth int, windowHeight int) (*Fon
resUniform := gl.GetUniformLocation(program, gl.Str("resolution\x00"))
gl.Uniform2f(resUniform, float32(windowWidth), float32(windowHeight))
return LoadTrueTypeFont(program, fd, scale, 32, 256, LeftToRight)
return LoadTrueTypeFont(program, fd, scale, LeftToRight)
}
// SetColor allows you to set the text color to be used when you draw the text
@@ -250,8 +359,6 @@ func (f *Font) Printf(x, y float32, scale float32, fs string, argv ...interface{
return nil
}
lowChar := rune(32)
// Setup blending mode
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
@@ -263,19 +370,17 @@ func (f *Font) Printf(x, y float32, scale float32, fs string, argv ...interface{
var coords []point
// Iterate through all characters in string
// Iterate through all glyphs in string
for i := range indices {
// Get rune
runeIndex := indices[i]
var ch *character
// Skip runes that are not in font chacter range
if int(runeIndex)-int(lowChar) > len(f.fontChar) || runeIndex < lowChar {
// Print a ?
ch = f.fontChar[int(rune('?'))-int(lowChar)]
var ch *glyph
// Skip runes that are not in font glyph map
if glyph, ok := f.glyphs[runeIndex]; ok {
ch = glyph
} else {
// Find rune in fontChar list
ch = f.fontChar[runeIndex-lowChar]
ch = f.glyphs['?'] // fallback
}
// Calculate position and size for current rune
@@ -325,23 +430,19 @@ func (f *Font) Width(scale float32, fs string, argv ...interface{}) float32 {
return 0
}
lowChar := rune(32)
// Iterate through all characters in string
// Iterate through all glyphs in string
for i := range indices {
// Get rune
runeIndex := indices[i]
// Skip runes that are not in font chacter range
if int(runeIndex)-int(lowChar) > len(f.fontChar) || runeIndex < lowChar {
fmt.Printf("%c %d\n", runeIndex, runeIndex)
continue
// Find rune in glyphs list
var ch *glyph
if glyph, ok := f.glyphs[runeIndex]; ok {
ch = glyph
} else {
ch = f.glyphs['?'] // fallback
}
// Find rune in fontChar list
ch := f.fontChar[runeIndex-lowChar]
// Now advance cursors for next glyph (note that advance is number of 1/64 pixels)
width += float32((ch.advance >> 6)) * scale // Bitshift by 6 to get value in pixels (2^6 = 64 (divide amount of 1/64th pixels by 64 to get amount of pixels))
+35
View File
@@ -0,0 +1,35 @@
package video
import (
"fmt"
"strings"
"time"
"github.com/libretro/ludo/ocr"
)
// OCRCurrentFrame captures the current frame and returns extracted text using the OCR engine.
func (video *Video) OCRCurrentFrame() (string, error) {
img, err := video.CaptureFrameImage()
if err != nil {
return "", fmt.Errorf("capture frame: %w", err)
}
text, err := ocr.TextFromImage(img)
if err != nil {
return "", fmt.Errorf("run OCR: %w", err)
}
return text, nil
}
// SetSubtitle displays text at the bottom of the screen for a given duration.
func (video *Video) SetSubtitle(text string, duration time.Duration) {
video.subtitleText = strings.TrimSpace(text)
if video.subtitleText == "" {
video.subtitleUntil = time.Time{}
return
}
video.subtitleUntil = time.Now().Add(duration)
}
+42 -15
View File
@@ -3,6 +3,7 @@ package video
import (
"image"
"image/png"
"math"
"os"
"path/filepath"
@@ -15,41 +16,69 @@ import (
// During the TakeScreenshot step, we need to render the current game frame at
// the right resolution to later capture it using ReadPixels. renderScreenshot
// taking care of this.
func (video *Video) renderScreenshot() {
va := video.vertexArray(0, 0, float32(video.Geom.BaseWidth), float32(video.Geom.BaseHeight), 1.0)
// draws with the core aspect ratio and returns the pixel bounds to read back.
func (video *Video) renderScreenshot() (int32, int32, int32, int32) {
fbw, fbh := video.Window.GetFramebufferSize()
w := float32(video.width)
h := float32(video.height)
if w == 0 || h == 0 {
w = float32(video.Geom.BaseWidth)
h = float32(video.Geom.BaseHeight)
}
// Center the core-resolution quad in the framebuffer.
x := (float32(fbw) - w) / 2
y := (float32(fbh) - h) / 2
va := video.vertexArray(x, y, w, h, 1.0)
gl.BindBuffer(gl.ARRAY_BUFFER, video.vbo)
gl.BufferData(gl.ARRAY_BUFFER, len(va)*4, gl.Ptr(va), gl.STATIC_DRAW)
readX := int32(math.Round(float64(x)))
readY := int32(math.Round(float64(fbh) - math.Round(float64(y+h))))
readW := int32(math.Round(float64(w)))
readH := int32(math.Round(float64(h)))
bindVertexArray(video.vao)
gl.BindTexture(gl.TEXTURE_2D, video.texID)
gl.BindBuffer(gl.ARRAY_BUFFER, video.vbo)
gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4)
return readX, readY, readW, readH
}
// TakeScreenshot captures the ouput of video.Render and writes it to a file
func (video *Video) TakeScreenshot(name string) error {
// captureFrameImage captures the output of video.Render and returns it as an image.
func (video *Video) captureFrameImage() (image.Image, error) {
prevMenu := state.MenuActive
state.MenuActive = false
defer func() { state.MenuActive = true }()
defer func() { state.MenuActive = prevMenu }()
gl.UseProgram(video.defaultProgram)
video.renderScreenshot()
readX, readY, readW, readH := video.renderScreenshot()
img := image.NewRGBA(image.Rect(0, 0, video.Geom.BaseWidth, video.Geom.BaseHeight))
_, fbh := video.Window.GetFramebufferSize()
img := image.NewRGBA(image.Rect(0, 0, int(readW), int(readH)))
gl.ReadPixels(
0, int32(fbh-video.Geom.BaseHeight),
int32(video.Geom.BaseWidth), int32(video.Geom.BaseHeight),
readX, readY,
readW, readH,
gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix))
gl.UseProgram(video.program)
err := os.MkdirAll(settings.Current.ScreenshotsDirectory, os.ModePerm)
return imaging.FlipV(img), nil
}
// CaptureFrameImage returns the current frame as an image.
func (video *Video) CaptureFrameImage() (image.Image, error) {
return video.captureFrameImage()
}
// TakeScreenshot captures the ouput of video.Render and writes it to a file
func (video *Video) TakeScreenshot(name string) error {
flipped, err := video.captureFrameImage()
if err != nil {
return err
}
@@ -60,7 +89,5 @@ func (video *Video) TakeScreenshot(name string) error {
return err
}
flipped := imaging.FlipV(img)
return png.Encode(fd, flipped)
}
+64
View File
@@ -0,0 +1,64 @@
package video
import (
"strings"
"time"
)
// RenderSubtitle draws the current subtitle, if any.
func (video *Video) RenderSubtitle() {
if video == nil || video.Font == nil || video.Window == nil {
return
}
if video.subtitleText == "" || time.Now().After(video.subtitleUntil) {
video.subtitleText = ""
return
}
fbw, fbh := video.Window.GetFramebufferSize()
if fbw == 0 || fbh == 0 {
return
}
lines := strings.Split(video.subtitleText, "\n")
ratio := float32(fbw) / 1920
scale := 0.6 * ratio
padding := 16 * ratio
lineHeight := 64 * ratio
margin := 50 * ratio
video.Font.UpdateResolution(fbw, fbh)
previousColor := video.Font.color
defer video.Font.SetColor(previousColor)
var maxWidth float32
for _, line := range lines {
w := video.Font.Width(scale, line)
if w > maxWidth {
maxWidth = w
}
}
bgW := maxWidth + padding*2
minBgW := float32(fbw) * 0.75
if bgW < minBgW {
bgW = minBgW
}
bgX := (float32(fbw) - bgW) / 2
bgH := lineHeight*float32(len(lines)) + padding*2
bgY := float32(fbh) - bgH
topY := bgY + padding
bgColor := Color{0, 0, 0, 0.75}
video.DrawRect(bgX, bgY - margin, bgW, bgH, 0.25, bgColor)
video.Font.SetColor(Color{1, 1, 1, 1})
for i, line := range lines {
y := topY + lineHeight*float32(i)
x := float32(fbw)/2 - video.Font.Width(scale, line)/2
video.Font.Printf(x, y, scale, line)
}
}
+4
View File
@@ -6,6 +6,7 @@ package video
import (
"log"
"path/filepath"
"time"
"unsafe"
"github.com/go-gl/gl/v2.1/gl"
@@ -40,6 +41,9 @@ type Video struct {
width, height int32 // dimensions set by the refresh callback
rot uint // rotation index
subtitleText string
subtitleUntil time.Time
needUpload bool // true when the texture needs to be uploaded to the GPU
data unsafe.Pointer
}