Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 93 additions & 11 deletions src/scripto/core/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
map is configurable; a language with no mapping gets ``.<code>`` so new
languages work without code changes (R3).

Reading that naming back is just as important: a video usually arrives with
subtitles beside it, and those are named the same way by everyone else too.
``sibling_transcripts`` finds them and names their languages, so an already
subtitled file is recognised for what it is instead of re-transcribed, and
history can show every language the file actually has.

Location: next to the source file by default; a user-chosen export directory
collects outputs instead (filename collisions across source folders get a
numeric disambiguator).
Expand All @@ -13,6 +19,7 @@
from __future__ import annotations

import json
import re
from pathlib import Path

from ..engines.base import TranscribeResult
Expand All @@ -21,6 +28,16 @@
DEFAULT_SUFFIXES = suffix_map() # single source of truth: core/languages.py
FORMATS = ("srt", "txt", "vtt", "json")

# ``lecture.srt`` next to ``lecture.mp4`` — no language in the name at all —
# is how subtitles ship from everywhere that isn't us, and they are English
# nearly every time. Better a named default than an "unknown" bucket.
UNSUFFIXED_LANGUAGE = "en"

# What we accept in the language position: "zh", "eng", "pt-BR". Anything
# else there (``lecture.part2.srt``, ``lecture.final.srt``) is part of the
# file's name, not a language, and must not be read as one.
_LANG_TAG_RE = re.compile(r"^[a-z]{2,3}(?:[-_][a-z]{2,4})?$")


def lang_suffix(language: str | None, suffix_map: dict[str, str] | None = None) -> str:
suffixes = suffix_map or DEFAULT_SUFFIXES
Expand Down Expand Up @@ -49,6 +66,71 @@ def output_path(
return candidate


def language_from_suffix(
suffix: str, suffix_map: dict[str, str] | None = None
) -> str | None:
"""The language a filename's middle suffix names, or None if it names none.

``.zh`` → ``zh``, an alias like ``.cn`` → ``zh``, nothing at all → English
(see ``UNSUFFIXED_LANGUAGE``), a well-formed but unregistered tag → itself
(writing does the mirror image: an unknown code becomes ``.<code>``), and
``.part2`` → None, because that is a filename, not a language.
"""
tag = suffix.lstrip(".").lower()
if not tag:
return UNSUFFIXED_LANGUAGE
for code, mapped in (suffix_map or DEFAULT_SUFFIXES).items():
if tag == mapped.lstrip(".").lower():
return code
for spec in known_languages():
if any(tag == alias.lstrip(".").lower() for alias in spec.aliases):
return spec.code
return tag if _LANG_TAG_RE.match(tag) else None


def sibling_transcripts(
source: Path,
*,
fmt: str,
suffix_map: dict[str, str] | None = None,
export_dir: Path | None = None,
) -> dict[str, Path]:
"""Language code → same-stem transcript already on disk beside ``source``.

Matches ``<stem>.<fmt>`` and ``<stem>.<lang>.<fmt>`` — one directory
listing, ordered by the language registry so callers get a stable
preference rather than whatever order the filesystem hands back.
"""
directory = export_dir if export_dir is not None else source.parent
prefix, tail = source.stem, f".{fmt}"
try:
entries = sorted(directory.iterdir())
except OSError:
return {}

found: dict[str, Path] = {}
for path in entries:
name = path.name
if not name.startswith(prefix) or not name.lower().endswith(tail.lower()):
continue
middle = name[len(prefix):len(name) - len(tail)]
if middle and not middle.startswith("."):
continue # `lecture-draft.srt` is a different file, not a language
language = language_from_suffix(middle, suffix_map)
if language is None or language in found:
continue
try:
if path.is_file():
found[language] = path
except OSError:
continue

order = {spec.code: i for i, spec in enumerate(known_languages())}
return dict(
sorted(found.items(), key=lambda kv: (order.get(kv[0], len(order)), kv[0]))
)


def existing_transcript(
source: Path,
*,
Expand All @@ -59,11 +141,10 @@ def existing_transcript(
) -> Path | None:
"""Skip-check before transcribing (overwrite=False).

With a forced language the exact path is checked. With auto-detect the
language isn't known yet, so any known-suffix sibling counts as done.
Export-dir runs always re-check the exact default name only.
With a forced language the exact path is checked, plus that language's
alias spellings — and, for English, the suffix-less name. With auto-detect
the language isn't known yet, so any sibling transcript counts as done.
"""
suffixes = suffix_map or DEFAULT_SUFFIXES
directory = export_dir if export_dir is not None else source.parent
if language:
path = output_path(
Expand All @@ -76,14 +157,15 @@ def existing_transcript(
candidate = directory / f"{source.stem}{suffix}.{fmt}"
if candidate.exists():
return candidate
if language == UNSUFFIXED_LANGUAGE:
bare = directory / f"{source.stem}.{fmt}"
if bare.exists():
return bare
return None
detect = list(suffixes.values())
detect += [a for spec in known_languages() for a in spec.aliases]
for suffix in detect:
candidate = directory / f"{source.stem}{suffix}.{fmt}"
if candidate.exists():
return candidate
return None
found = sibling_transcripts(
source, fmt=fmt, suffix_map=suffix_map, export_dir=export_dir
)
return next(iter(found.values()), None)


# ---------------------------------------------------------------------------
Expand Down
46 changes: 38 additions & 8 deletions src/scripto/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ def _transcribe_loop(
job.outputs.append(existing)
stats.skipped += 1
self._emit_status(job)
# Skipping still belongs in history: the file has subtitles,
# and the user wants to reach them (view, play, translate)
# from there like any other result.
self._record(job)
self._queue_translation(job, existing, translate_q, deferred, translate_now, stop)
continue

Expand Down Expand Up @@ -431,19 +435,45 @@ def _record_translation(self, job: Job, produced: list[Path]) -> None:
except Exception:
logger.exception("could not write translation history entry")

def _output_rows(self, job: Job) -> list[dict[str, str]]:
"""Every transcript this source has now, with its language named.

The job's own products first — their language is the detected one,
which beats guessing from a filename — then any same-stem siblings
already on disk: subtitles that shipped with the video, or that an
earlier run produced in another language. A source appears in
history with all of its languages, not just the one this run touched.
"""
rows: dict[str, dict[str, str]] = {}

def row(path: Path, lang: str) -> dict[str, str]:
return {
"lang": lang,
"format": path.suffix.lstrip("."),
"path": str(path),
}

for path in job.outputs:
rows[str(path)] = row(path, job.language or "")
if job.status in (JobStatus.DONE, JobStatus.SKIPPED):
siblings = out.sibling_transcripts(
job.source, fmt=self._s.fmt,
suffix_map=self._s.suffix_map, export_dir=self._s.export_dir,
)
for language, path in siblings.items():
known = rows.get(str(path))
if known is None:
rows[str(path)] = row(path, language)
elif not known["lang"]:
known["lang"] = language # skipped job: the name is all we have
return list(rows.values())

def _record(self, job: Job, duration: float = 0.0) -> None:
try:
self._history.append(
HistoryEntry(
source=str(job.source),
outputs=[
{
"lang": job.language or "",
"format": path.suffix.lstrip("."),
"path": str(path),
}
for path in job.outputs
],
outputs=self._output_rows(job),
model=self._s.model.key,
engine=self._s.engine_label,
status=job.status.value if job.status != JobStatus.PENDING else "failed",
Expand Down
121 changes: 121 additions & 0 deletions src/scripto/gui_qt/icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Painted transport icons for the player.

Emoji glyphs (⏪ ⏩ ⏸) render differently on every platform and never match
the app's own weight or color, so the transport controls draw their own:
a circular arrow with the skip amount inside, plus play/pause. Everything
is a vector path painted into a device-pixel-ratio-correct pixmap, so the
icons follow the theme's text color and stay crisp on retina displays.
"""

from __future__ import annotations

import math

from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPixmap

# Gap left at the top of the circle for the arrowhead, in degrees.
_ARC_START = 120
_ARC_SPAN = 300


def _canvas(size: int, ratio: float) -> QPixmap:
pixmap = QPixmap(round(size * ratio), round(size * ratio))
pixmap.setDevicePixelRatio(ratio)
pixmap.fill(Qt.GlobalColor.transparent)
return pixmap


def _painter(pixmap: QPixmap) -> QPainter:
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
return painter


def skip_icon(seconds: int, *, forward: bool, color: str,
size: int = 24, ratio: float = 2.0) -> QIcon:
"""A circular arrow with ``seconds`` inside — ⟳10 / ⟲10."""
pixmap = _canvas(size, ratio)
painter = _painter(pixmap)
tint = QColor(color)

painter.save()
if not forward: # the back arrow is the forward one, mirrored
painter.translate(size, 0)
painter.scale(-1, 1)

margin = size * 0.14
rect = QRectF(margin, margin, size - 2 * margin, size - 2 * margin)
pen = QPen(tint)
pen.setWidthF(size * 0.085)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawArc(rect, _ARC_START * 16, _ARC_SPAN * 16)

# Arrowhead at the arc's open end, aimed along the clockwise tangent.
theta = math.radians(_ARC_START)
radius = rect.width() / 2
end_x = rect.center().x() + radius * math.cos(theta)
end_y = rect.center().y() - radius * math.sin(theta)
dir_x, dir_y = math.sin(theta), math.cos(theta) # clockwise tangent
perp_x, perp_y = -dir_y, dir_x
# The head continues the stroke rather than straddling its end, so the
# arrow reads as one line that grew a point.
length, half = size * 0.21, size * 0.105
base_x, base_y = end_x - dir_x * length * 0.25, end_y - dir_y * length * 0.25
head = QPainterPath()
head.moveTo(base_x + dir_x * length, base_y + dir_y * length)
head.lineTo(base_x + perp_x * half, base_y + perp_y * half)
head.lineTo(base_x - perp_x * half, base_y - perp_y * half)
head.closeSubpath()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(tint)
painter.drawPath(head)
painter.restore()

font = QFont()
font.setPixelSize(max(7, round(size * 0.40)))
font.setBold(True)
painter.setFont(font)
painter.setPen(tint)
painter.drawText(
rect.adjusted(0, size * 0.045, 0, size * 0.045),
Qt.AlignmentFlag.AlignCenter,
str(seconds),
)
painter.end()
return QIcon(pixmap)


def play_icon(color: str, size: int = 24, ratio: float = 2.0) -> QIcon:
pixmap = _canvas(size, ratio)
painter = _painter(pixmap)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(color))
triangle = QPainterPath()
triangle.moveTo(QPointF(size * 0.30, size * 0.20))
triangle.lineTo(QPointF(size * 0.80, size * 0.50))
triangle.lineTo(QPointF(size * 0.30, size * 0.80))
triangle.closeSubpath()
painter.drawPath(triangle)
painter.end()
return QIcon(pixmap)


def pause_icon(color: str, size: int = 24, ratio: float = 2.0) -> QIcon:
pixmap = _canvas(size, ratio)
painter = _painter(pixmap)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(color))
bar, gap = size * 0.15, size * 0.12
top, height = size * 0.21, size * 0.58
left = (size - (2 * bar + gap)) / 2
radius = bar * 0.35
painter.drawRoundedRect(QRectF(left, top, bar, height), radius, radius)
painter.drawRoundedRect(
QRectF(left + bar + gap, top, bar, height), radius, radius
)
painter.end()
return QIcon(pixmap)
Loading
Loading