Current section

Files

Jump to
dala lib dala media subtitle.ex
Raw

lib/dala/media/subtitle.ex

defmodule Dala.Media.Subtitle do
@moduledoc """
Timestamp-synchronized subtitle rendering.
Supports SRT and WebVTT formats. Subtitles are rendered as GPU overlays
synchronized with the media clock.
Architecture:
Subtitle File → Parser → Cue Timeline → Clock Sync → GPU Overlay
## Example
{:ok, sub} = Dala.Media.Subtitle.load("subtitles.srt")
# In your screen's handle_info:
def handle_info({:clock, :tick, %{timestamp_us: ts}}, socket) do
case Dala.Media.Subtitle.active_cue(sub, ts) do
nil -> {:noreply, socket}
cue -> {:noreply, render_subtitle(socket, cue)}
end
end
"""
@type cue :: %{
id: non_neg_integer(),
start_ms: non_neg_integer(),
end_ms: non_neg_integer(),
text: String.t(),
style: map()
}
@type parse_result :: {:ok, [cue()]} | {:error, term()}
@doc "Parse SRT content into a list of cues."
@spec parse_srt(String.t()) :: parse_result()
def parse_srt(content) when is_binary(content) do
content
|> String.split(~r/\r?\n\r?\n/, trim: true)
|> Enum.with_index(1)
|> Enum.reduce_while({:ok, []}, fn {block, idx}, {:ok, acc} ->
case parse_srt_block(block, idx) do
{:ok, cue} -> {:cont, {:ok, [cue | acc]}}
{:error, reason} -> {:halt, {:error, {idx, reason}}}
end
end)
|> then(fn {:ok, cues} -> {:ok, Enum.reverse(cues)} end)
end
@doc "Parse WebVTT content into a list of cues."
@spec parse_vtt(String.t()) :: parse_result()
def parse_vtt(<<"WEBVTT", rest::binary>>) do
rest
|> String.split(~r/\r?\n\r?\n/, trim: true)
|> Enum.with_index(1)
|> Enum.reduce_while({:ok, []}, fn {block, idx}, {:ok, acc} ->
case parse_vtt_block(block, idx) do
{:ok, cue} -> {:cont, {:ok, [cue | acc]}}
{:error, _} -> {:cont, {:ok, acc}}
end
end)
|> then(fn {:ok, cues} -> {:ok, Enum.reverse(cues)} end)
end
def parse_vtt(_), do: {:error, :invalid_vtt_header}
@doc "Find the active cue for a given timestamp (in microseconds)."
@spec active_cue([cue()], non_neg_integer()) :: cue() | nil
def active_cue(cues, timestamp_us) do
timestamp_ms = div(timestamp_us, 1000)
Enum.find(cues, fn cue ->
timestamp_ms >= cue.start_ms and timestamp_ms <= cue.end_ms
end)
end
@doc "Get all cues that fall within a time range."
@spec cues_in_range([cue()], non_neg_integer(), non_neg_integer()) :: [cue()]
def cues_in_range(cues, start_us, end_us) do
start_ms = div(start_us, 1000)
end_ms = div(end_us, 1000)
Enum.filter(cues, fn cue ->
(cue.start_ms >= start_ms and cue.start_ms <= end_ms) or
(cue.end_ms >= start_ms and cue.end_ms <= end_ms) or
(cue.start_ms <= start_ms and cue.end_ms >= end_ms)
end)
end
@doc "Format a cue for GPU overlay rendering."
@spec to_overlay(cue(), keyword()) :: map()
def to_overlay(cue, opts \\ []) do
%{
type: :text,
text: cue.text,
position: Keyword.get(opts, :position, {0, 0}),
font_size: Keyword.get(opts, :font_size, 24),
color: Keyword.get(opts, :color, {255, 255, 255, 255}),
background: Keyword.get(opts, :background, {0, 0, 0, 128}),
max_width: Keyword.get(opts, :max_width, 600)
}
end
# Private — SRT parsing
defp parse_srt_block(block, _idx) do
lines = String.split(block, ~r/\r?\n/, trim: true)
case lines do
[id_str, timing | text_lines] ->
with {id, ""} <- Integer.parse(id_str),
{:ok, start_ms, end_ms} <- parse_srt_timing(timing) do
{:ok,
%{
id: id,
start_ms: start_ms,
end_ms: end_ms,
text: Enum.join(text_lines, "\n"),
style: %{}
}}
else
_ -> {:error, :invalid_block}
end
_ ->
{:error, :invalid_block}
end
end
defp parse_srt_timing(timing) do
case Regex.run(
~r/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/,
timing
) do
[_, sh, sm, ss, sms, eh, em, es, ems] ->
start_ms = to_ms(sh, sm, ss, sms)
end_ms = to_ms(eh, em, es, ems)
{:ok, start_ms, end_ms}
_ ->
{:error, :invalid_timing}
end
end
defp to_ms(h, m, s, ms) do
String.to_integer(h) * 3_600_000 +
String.to_integer(m) * 60_000 +
String.to_integer(s) * 1_000 +
String.to_integer(ms)
end
# Private — VTT parsing
defp parse_vtt_block(block, idx) do
lines = String.split(block, ~r/\r?\n/, trim: true)
case lines do
[timing | text_lines] when text_lines != [] ->
case parse_vtt_timing(timing) do
{:ok, start_ms, end_ms} ->
{:ok,
%{
id: idx,
start_ms: start_ms,
end_ms: end_ms,
text: Enum.join(text_lines, "\n"),
style: %{}
}}
error ->
error
end
_ ->
{:error, :invalid_block}
end
end
defp parse_vtt_timing(timing) do
case Regex.run(
~r/(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})\.(\d{3})/,
timing
) do
[_, sh, sm, ss, sms, eh, em, es, ems] ->
start_ms = to_ms(sh, sm, ss, sms)
end_ms = to_ms(eh, em, es, ems)
{:ok, start_ms, end_ms}
_ ->
# Try without hours
case Regex.run(~r/(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2})\.(\d{3})/, timing) do
[_, sm, ss, sms, em, es, ems] ->
start_ms = to_ms("00", sm, ss, sms)
end_ms = to_ms("00", em, es, ems)
{:ok, start_ms, end_ms}
_ ->
{:error, :invalid_timing}
end
end
end
end