File size: 2,311 Bytes
ef4c36f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { v4 as uuidv4 } from "uuid";
import type { Caption } from "@/types";

const BASE_WORD_MS = 180;
const CHAR_MS = 40;
const GAP_MS = 60;

// ponytail: phrases of 6 read far better than one word at a time, and each cue
// stays on screen ~6x longer without touching playback speed
export const WORDS_PER_CAPTION = 6;

function wordDuration(text: string): number {
  return BASE_WORD_MS + text.length * CHAR_MS;
}

function groupWords(text: string, perGroup = WORDS_PER_CAPTION): string[] {
  const words = text.trim().split(/\s+/).filter(Boolean);
  const groups: string[] = [];

  for (let i = 0; i < words.length; i += perGroup) {
    groups.push(words.slice(i, i + perGroup).join(" "));
  }

  // ponytail: a 1-2 word orphan at the end flashes by — fold it into the previous phrase
  if (groups.length > 1 && groups[groups.length - 1].split(" ").length <= 2) {
    const orphan = groups.pop() as string;
    groups[groups.length - 1] = `${groups[groups.length - 1]} ${orphan}`;
  }

  return groups;
}

export function autoSyncCaptions(text: string, speed = 1): Caption[] {
  const phrases = groupWords(text);

  let cursor = 0;
  return phrases.map((phrase) => {
    const duration =
      phrase.split(" ").reduce((sum, w) => sum + wordDuration(w), 0) / speed;
    const caption: Caption = {
      id: uuidv4(),
      text: phrase,
      startMs: cursor,
      endMs: cursor + duration,
    };
    cursor += duration + GAP_MS / speed;
    return caption;
  });
}

export function syncCaptionsToDuration(text: string, totalDurationMs: number): Caption[] {
  const phrases = groupWords(text);
  if (phrases.length === 0) return [];

  // weight by character count so a long phrase holds the screen longer
  const weights = phrases.map((p) => Math.max(p.length, 1));
  const totalWeight = weights.reduce((a, b) => a + b, 0);
  let cursor = 0;

  return phrases.map((phrase, index) => {
    const duration = (weights[index] / totalWeight) * totalDurationMs;
    const caption: Caption = {
      id: uuidv4(),
      text: phrase.replace(/[,;:]+$/, ""),
      startMs: Math.round(cursor),
      endMs: Math.round(cursor + duration),
    };
    cursor += duration;
    return caption;
  });
}

export function captionsToText(captions: Caption[]): string {
  return captions.map((c) => c.text).join(" ");
}