feat(overlay): improve provisional text display and line splitting
This commit is contained in:
@@ -383,8 +383,10 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
||||
// view state, rather than GTK ellipsizing, enforces its six-entry limit.
|
||||
gtk_label_set_lines(GTK_LABEL(overlay->final_label), -1);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(overlay->final_label), PANGO_ELLIPSIZE_NONE);
|
||||
gtk_label_set_lines(GTK_LABEL(overlay->preview_label), 2);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(overlay->preview_label), PANGO_ELLIPSIZE_END);
|
||||
// Provisional text is already split and bounded by the Go view state. Let
|
||||
// GTK allocate all of those lines instead of replacing overflow with "…".
|
||||
gtk_label_set_lines(GTK_LABEL(overlay->preview_label), -1);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(overlay->preview_label), PANGO_ELLIPSIZE_NONE);
|
||||
gtk_widget_add_css_class(overlay->final_label, "captioneer-final");
|
||||
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
|
||||
gtk_window_set_child(overlay->window, box);
|
||||
@@ -471,15 +473,21 @@ import (
|
||||
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||||
)
|
||||
|
||||
const finalFadeRefreshInterval = 200 * time.Millisecond
|
||||
const (
|
||||
finalFadeRefreshInterval = 200 * time.Millisecond
|
||||
averageGlyphWidthFactor = 0.58
|
||||
minimumLineCharacters = 20
|
||||
maximumLineCharacters = 64
|
||||
)
|
||||
|
||||
type Sink struct {
|
||||
native *C.CaptioneerOverlay
|
||||
finalTimeout time.Duration
|
||||
finalTimer *time.Timer
|
||||
state viewState
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
native *C.CaptioneerOverlay
|
||||
finalTimeout time.Duration
|
||||
finalTimer *time.Timer
|
||||
lineCharacters int
|
||||
state viewState
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(settings config.OverlaySettings) (*Sink, string, error) {
|
||||
@@ -512,7 +520,11 @@ func New(settings config.OverlaySettings) (*Sink, string, error) {
|
||||
}
|
||||
return nil, warning, errors.New(message)
|
||||
}
|
||||
return &Sink{native: native, finalTimeout: settings.FinalTimeout}, warning, nil
|
||||
return &Sink{
|
||||
native: native,
|
||||
finalTimeout: settings.FinalTimeout,
|
||||
lineCharacters: overlayLineCharacters(settings),
|
||||
}, warning, nil
|
||||
}
|
||||
|
||||
func (s *Sink) Publish(event captions.Event) error {
|
||||
@@ -522,17 +534,26 @@ func (s *Sink) Publish(event captions.Event) error {
|
||||
return errors.New("overlay is closed")
|
||||
}
|
||||
now := time.Now()
|
||||
s.state.Apply(event, now, s.finalTimeout)
|
||||
if event.Kind == captions.Final || event.Kind == captions.Hide {
|
||||
s.stopFinalTimer()
|
||||
}
|
||||
s.state.Apply(event, now, s.finalTimeout, s.lineCharacters)
|
||||
if event.Kind == captions.Final {
|
||||
s.stopFinalTimer()
|
||||
s.scheduleFinalRefresh(now)
|
||||
}
|
||||
s.postState()
|
||||
return nil
|
||||
}
|
||||
|
||||
func overlayLineCharacters(settings config.OverlaySettings) int {
|
||||
characters := int(float64(settings.MaxWidth) / (float64(settings.FontSize) * averageGlyphWidthFactor))
|
||||
if characters < minimumLineCharacters {
|
||||
return minimumLineCharacters
|
||||
}
|
||||
if characters > maximumLineCharacters {
|
||||
return maximumLineCharacters
|
||||
}
|
||||
return characters
|
||||
}
|
||||
|
||||
func (s *Sink) refreshFinals() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
+113
-11
@@ -3,16 +3,20 @@ package overlay
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
const (
|
||||
maxVisibleFinalLines = 6
|
||||
maxVisiblePreviewLines = 3
|
||||
oldestLineBrightness = 0.65
|
||||
defaultLineFadeDuration = 3 * time.Second
|
||||
readingWordsPerSecond = 3.0
|
||||
)
|
||||
|
||||
type finalLine struct {
|
||||
@@ -26,26 +30,39 @@ type viewState struct {
|
||||
Provisional string
|
||||
}
|
||||
|
||||
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration) {
|
||||
func (s *viewState) Apply(event captions.Event, now time.Time, finalTimeout time.Duration, maxLineCharacters int) {
|
||||
switch event.Kind {
|
||||
case captions.Provisional:
|
||||
s.Provisional = strings.TrimSpace(event.Text)
|
||||
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||
if len(chunks) > maxVisiblePreviewLines {
|
||||
chunks = chunks[len(chunks)-maxVisiblePreviewLines:]
|
||||
}
|
||||
s.Provisional = strings.Join(chunks, "\n")
|
||||
case captions.Final:
|
||||
text := strings.TrimSpace(event.Text)
|
||||
if text == "" {
|
||||
chunks := splitCaptionText(event.Text, maxLineCharacters)
|
||||
if len(chunks) == 0 {
|
||||
return
|
||||
}
|
||||
line := finalLine{CreatedAt: now, Text: text}
|
||||
if finalTimeout > 0 {
|
||||
line.ExpiresAt = now.Add(finalTimeout)
|
||||
if len(chunks) > maxVisibleFinalLines {
|
||||
chunks = chunks[len(chunks)-maxVisibleFinalLines:]
|
||||
}
|
||||
expiresAt := now
|
||||
for _, text := range chunks {
|
||||
line := finalLine{CreatedAt: now, Text: text}
|
||||
if finalTimeout > 0 {
|
||||
expiresAt = expiresAt.Add(readingLifetime(text, finalTimeout))
|
||||
line.ExpiresAt = expiresAt
|
||||
}
|
||||
s.Finals = append(s.Finals, line)
|
||||
}
|
||||
s.Finals = append(s.Finals, line)
|
||||
if len(s.Finals) > maxVisibleFinalLines {
|
||||
s.Finals = s.Finals[len(s.Finals)-maxVisibleFinalLines:]
|
||||
}
|
||||
s.Provisional = ""
|
||||
case captions.Hide:
|
||||
*s = viewState{}
|
||||
// A blank recognition result must not erase completed captions before
|
||||
// their individual reading lifetimes have elapsed.
|
||||
s.Provisional = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +101,11 @@ func (s viewState) FinalMarkup(now time.Time, lineLifetime time.Duration) string
|
||||
if i > 0 {
|
||||
markup.WriteByte('\n')
|
||||
}
|
||||
brightness := lineBrightness(now.Sub(line.CreatedAt), fadeDuration)
|
||||
lineFadeDuration := fadeDuration
|
||||
if !line.ExpiresAt.IsZero() {
|
||||
lineFadeDuration = line.ExpiresAt.Sub(line.CreatedAt)
|
||||
}
|
||||
brightness := lineBrightness(now.Sub(line.CreatedAt), lineFadeDuration)
|
||||
channel := int(brightness*255 + 0.5)
|
||||
fmt.Fprintf(&markup, `<span foreground="#%02X%02X%02X">`, channel, channel, channel)
|
||||
markup.WriteString(html.EscapeString(line.Text))
|
||||
@@ -99,13 +120,94 @@ func (s viewState) NeedsFadeRefresh(now time.Time, lineLifetime time.Duration) b
|
||||
fadeDuration = defaultLineFadeDuration
|
||||
}
|
||||
for _, line := range s.Finals {
|
||||
if now.Sub(line.CreatedAt) < fadeDuration {
|
||||
lineFadeDuration := fadeDuration
|
||||
if !line.ExpiresAt.IsZero() {
|
||||
lineFadeDuration = line.ExpiresAt.Sub(line.CreatedAt)
|
||||
}
|
||||
if now.Sub(line.CreatedAt) < lineFadeDuration {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitCaptionText(text string, maxCharacters int) []string {
|
||||
if maxCharacters < 1 {
|
||||
maxCharacters = 1
|
||||
}
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
var current strings.Builder
|
||||
currentCharacters := 0
|
||||
flush := func() {
|
||||
if currentCharacters == 0 {
|
||||
return
|
||||
}
|
||||
chunks = append(chunks, current.String())
|
||||
current.Reset()
|
||||
currentCharacters = 0
|
||||
}
|
||||
|
||||
for _, word := range words {
|
||||
wordCharacters := utf8.RuneCountInString(word)
|
||||
if wordCharacters > maxCharacters {
|
||||
flush()
|
||||
runes := []rune(word)
|
||||
for len(runes) > maxCharacters {
|
||||
chunks = append(chunks, string(runes[:maxCharacters]))
|
||||
runes = runes[maxCharacters:]
|
||||
}
|
||||
if len(runes) > 0 {
|
||||
current.WriteString(string(runes))
|
||||
currentCharacters = len(runes)
|
||||
}
|
||||
} else {
|
||||
separatorCharacters := 0
|
||||
if currentCharacters > 0 {
|
||||
separatorCharacters = 1
|
||||
}
|
||||
if currentCharacters+separatorCharacters+wordCharacters > maxCharacters {
|
||||
flush()
|
||||
separatorCharacters = 0
|
||||
}
|
||||
if separatorCharacters > 0 {
|
||||
current.WriteByte(' ')
|
||||
currentCharacters++
|
||||
}
|
||||
current.WriteString(word)
|
||||
currentCharacters += wordCharacters
|
||||
}
|
||||
|
||||
if endsSentence(word) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
func endsSentence(word string) bool {
|
||||
trimmed := strings.TrimRight(word, `"'”’)]}`)
|
||||
return strings.HasSuffix(trimmed, ".") || strings.HasSuffix(trimmed, "!") || strings.HasSuffix(trimmed, "?")
|
||||
}
|
||||
|
||||
func readingLifetime(text string, minimum time.Duration) time.Duration {
|
||||
if minimum <= 0 {
|
||||
return 0
|
||||
}
|
||||
words := len(strings.Fields(text))
|
||||
readingSeconds := math.Ceil(float64(words) / readingWordsPerSecond)
|
||||
readingTime := time.Duration(readingSeconds * float64(time.Second))
|
||||
if readingTime < minimum {
|
||||
return minimum
|
||||
}
|
||||
return readingTime
|
||||
}
|
||||
|
||||
func (s viewState) NextFinalExpiry() time.Time {
|
||||
if len(s.Finals) == 0 {
|
||||
return time.Time{}
|
||||
|
||||
@@ -7,29 +7,31 @@ import (
|
||||
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||||
)
|
||||
|
||||
const testLineCharacters = 80
|
||||
|
||||
func TestViewStateKeepsCompletedLinesAboveNewProvisional(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: " first final "}, now, 4*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: " second final "}, now, 4*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " first draft "}, now, 4*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: " first final "}, now, 4*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: " second final "}, now, 4*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: " next draft "}, now, 4*time.Second, testLineCharacters)
|
||||
|
||||
if state.FinalText() != "first final\nsecond final" || state.Provisional != "next draft" {
|
||||
t.Fatalf("unexpected rows: %#v", state)
|
||||
}
|
||||
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second)
|
||||
if state.FinalText() != "" || state.Provisional != "" {
|
||||
t.Fatalf("hide did not clear rows: %#v", state)
|
||||
state.Apply(captions.Event{Kind: captions.Hide}, now, 4*time.Second, testLineCharacters)
|
||||
if state.FinalText() != "first final\nsecond final" || state.Provisional != "" {
|
||||
t.Fatalf("blank result erased completed captions: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewStateExpiresEachCompletedLineAfterItsOwnLifetime(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, now, 3*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "second"}, now.Add(time.Second), 3*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 3*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "first"}, now, 3*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "second"}, now.Add(time.Second), 3*time.Second, testLineCharacters)
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "draft"}, now, 3*time.Second, testLineCharacters)
|
||||
|
||||
if state.ExpireFinal(now.Add(2999 * time.Millisecond)) {
|
||||
t.Fatal("completed line expired early")
|
||||
@@ -46,7 +48,7 @@ func TestViewStateLimitsVisibleCompletedLines(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
for i := 0; i <= maxVisibleFinalLines; i++ {
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: string(rune('a' + i))}, now, 3*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: string(rune('a' + i))}, now, 3*time.Second, testLineCharacters)
|
||||
}
|
||||
if len(state.Finals) != maxVisibleFinalLines || state.FinalText() != "b\nc\nd\ne\nf\ng" {
|
||||
t.Fatalf("unexpected completed-line limit: %#v", state)
|
||||
@@ -56,7 +58,7 @@ func TestViewStateLimitsVisibleCompletedLines(t *testing.T) {
|
||||
func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 0)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "final"}, now, 0, testLineCharacters)
|
||||
if state.ExpireFinal(now.Add(24 * time.Hour)) {
|
||||
t.Fatal("zero-timeout caption expired")
|
||||
}
|
||||
@@ -65,7 +67,7 @@ func TestViewStateZeroTimeoutDoesNotExpire(t *testing.T) {
|
||||
func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "<hello & goodbye>"}, now, 3*time.Second)
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "<hello & goodbye>"}, now, 3*time.Second, testLineCharacters)
|
||||
|
||||
if got := state.FinalMarkup(now, 3*time.Second); got != `<span foreground="#FFFFFF"><hello & goodbye></span>` {
|
||||
t.Fatalf("new line markup = %q", got)
|
||||
@@ -74,3 +76,54 @@ func TestViewStateFinalMarkupFadesAndEscapesText(t *testing.T) {
|
||||
t.Fatalf("old line markup = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCaptionTextUsesSentencesAndWordBoundaries(t *testing.T) {
|
||||
got := splitCaptionText("Hello there. This sentence should wrap cleanly at words.", 18)
|
||||
want := []string{"Hello there.", "This sentence", "should wrap", "cleanly at words."}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("chunks = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("chunks = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionalCaptionKeepsOnlyLatestDigestibleChunks(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Provisional, Text: "One. Two. Three. Four."}, now, 3*time.Second, testLineCharacters)
|
||||
if state.Provisional != "Two.\nThree.\nFour." {
|
||||
t.Fatalf("provisional text = %q", state.Provisional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadingLifetimeGrowsWithWordCount(t *testing.T) {
|
||||
text := "one two three four five six seven eight nine ten eleven twelve"
|
||||
if got := readingLifetime(text, 3*time.Second); got != 4*time.Second {
|
||||
t.Fatalf("reading lifetime = %s, want 4s", got)
|
||||
}
|
||||
if got := readingLifetime("short caption", 3*time.Second); got != 3*time.Second {
|
||||
t.Fatalf("short reading lifetime = %s, want 3s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizedChunksExpireInReadingOrder(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var state viewState
|
||||
state.Apply(captions.Event{Kind: captions.Final, Text: "First sentence. Second sentence."}, now, 3*time.Second, testLineCharacters)
|
||||
|
||||
if len(state.Finals) != 2 {
|
||||
t.Fatalf("final chunks = %#v", state.Finals)
|
||||
}
|
||||
if state.Finals[0].ExpiresAt != now.Add(3*time.Second) {
|
||||
t.Fatalf("first expiry = %s", state.Finals[0].ExpiresAt)
|
||||
}
|
||||
if state.Finals[1].ExpiresAt != now.Add(6*time.Second) {
|
||||
t.Fatalf("second expiry = %s", state.Finals[1].ExpiresAt)
|
||||
}
|
||||
if !state.ExpireFinal(now.Add(3*time.Second)) || state.FinalText() != "Second sentence." {
|
||||
t.Fatalf("unexpected state after first reading window: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user