add Moss result to db
This commit is contained in:
@@ -4,7 +4,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .audio_metrics import (
|
from .audio_metrics import (
|
||||||
AudioAnalysisError,
|
AudioAnalysisError,
|
||||||
@@ -124,7 +124,11 @@ class VideoProcessor:
|
|||||||
)
|
)
|
||||||
if not document.sentences:
|
if not document.sentences:
|
||||||
raise RuntimeError("MOSS returned no timestamped speech segments.")
|
raise RuntimeError("MOSS returned no timestamped speech segments.")
|
||||||
self._finish_processing(document, transcript.text)
|
self._finish_processing(
|
||||||
|
document,
|
||||||
|
transcript.text,
|
||||||
|
moss_segments=_serialize_moss_segments(transcript),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
entry, _ = make_entry(media_path, video_hash=video_hash)
|
entry, _ = make_entry(media_path, video_hash=video_hash)
|
||||||
document = SentenceBoundaryDocument(
|
document = SentenceBoundaryDocument(
|
||||||
@@ -145,13 +149,18 @@ class VideoProcessor:
|
|||||||
self,
|
self,
|
||||||
document: SentenceBoundaryDocument,
|
document: SentenceBoundaryDocument,
|
||||||
transcription: Optional[str],
|
transcription: Optional[str],
|
||||||
|
moss_segments: Optional[List[Dict[str, Any]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.oss_uploader is None or not self.oss_uploader.enabled:
|
if self.oss_uploader is None or not self.oss_uploader.enabled:
|
||||||
self.repository.save_processing_result(document, transcription, ready=True)
|
self.repository.save_processing_result(
|
||||||
|
document, transcription, ready=True, moss_segments=moss_segments
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Keep the course invisible while the mandatory OSS upload is running.
|
# Keep the course invisible while the mandatory OSS upload is running.
|
||||||
self.repository.save_processing_result(document, transcription, ready=False)
|
self.repository.save_processing_result(
|
||||||
|
document, transcription, ready=False, moss_segments=moss_segments
|
||||||
|
)
|
||||||
video = self.repository.get_video(document.video_hash)
|
video = self.repository.get_video(document.video_hash)
|
||||||
if video is None:
|
if video is None:
|
||||||
raise ValueError(f"Unknown video: {document.video_hash}")
|
raise ValueError(f"Unknown video: {document.video_hash}")
|
||||||
@@ -539,3 +548,32 @@ def _media_duration_ms(path: Path) -> int:
|
|||||||
from .generate_boundaries import media_duration_ms
|
from .generate_boundaries import media_duration_ms
|
||||||
|
|
||||||
return media_duration_ms(path)
|
return media_duration_ms(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_moss_segments(transcript: Transcript) -> List[Dict[str, Any]]:
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
for segment in transcript.segments:
|
||||||
|
text = segment.text.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
words = None
|
||||||
|
if segment.words:
|
||||||
|
words = [
|
||||||
|
{
|
||||||
|
"start_seconds": word.start_seconds,
|
||||||
|
"end_seconds": word.end_seconds,
|
||||||
|
"text": word.text,
|
||||||
|
}
|
||||||
|
for word in segment.words
|
||||||
|
]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"start_seconds": segment.start_seconds,
|
||||||
|
"end_seconds": segment.end_seconds,
|
||||||
|
"text": text,
|
||||||
|
"speaker": segment.speaker,
|
||||||
|
"words": words,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,20 @@ class VideoRepository:
|
|||||||
FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE,
|
FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE,
|
||||||
UNIQUE (share_id, sentence_index)
|
UNIQUE (share_id, sentence_index)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS moss_segments (
|
||||||
|
video_hash TEXT NOT NULL,
|
||||||
|
segment_index INTEGER NOT NULL,
|
||||||
|
start_seconds REAL NOT NULL,
|
||||||
|
end_seconds REAL NOT NULL,
|
||||||
|
start_ms INTEGER NOT NULL,
|
||||||
|
end_ms INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
speaker TEXT,
|
||||||
|
words_json TEXT,
|
||||||
|
PRIMARY KEY (video_hash, segment_index),
|
||||||
|
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,12 +261,17 @@ class VideoRepository:
|
|||||||
document: SentenceBoundaryDocument,
|
document: SentenceBoundaryDocument,
|
||||||
transcription: Optional[str],
|
transcription: Optional[str],
|
||||||
ready: bool = True,
|
ready: bool = True,
|
||||||
|
moss_segments: Optional[List[Dict[str, Any]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"DELETE FROM sentences WHERE video_hash = ?",
|
"DELETE FROM sentences WHERE video_hash = ?",
|
||||||
(document.video_hash,),
|
(document.video_hash,),
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM moss_segments WHERE video_hash = ?",
|
||||||
|
(document.video_hash,),
|
||||||
|
)
|
||||||
connection.executemany(
|
connection.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sentences (
|
INSERT INTO sentences (
|
||||||
@@ -288,6 +307,19 @@ class VideoRepository:
|
|||||||
document.video_hash,
|
document.video_hash,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if moss_segments:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO moss_segments (
|
||||||
|
video_hash, segment_index, start_seconds, end_seconds,
|
||||||
|
start_ms, end_ms, text, speaker, words_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
_moss_segment_row(document.video_hash, index, segment)
|
||||||
|
for index, segment in enumerate(moss_segments)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
|
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
@@ -327,6 +359,32 @@ class VideoRepository:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_moss_segments(self, video_hash: str) -> List[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM moss_segments
|
||||||
|
WHERE video_hash = ?
|
||||||
|
ORDER BY segment_index
|
||||||
|
""",
|
||||||
|
(normalize_video_hash(video_hash),),
|
||||||
|
).fetchall()
|
||||||
|
segments = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
raw_words = item.pop("words_json", None)
|
||||||
|
words = None
|
||||||
|
if raw_words:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_words)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
words = parsed
|
||||||
|
item["words"] = words
|
||||||
|
segments.append(item)
|
||||||
|
return segments
|
||||||
|
|
||||||
def get_document(self, video_hash: str) -> Optional[SentenceBoundaryDocument]:
|
def get_document(self, video_hash: str) -> Optional[SentenceBoundaryDocument]:
|
||||||
video = self.get_video(video_hash)
|
video = self.get_video(video_hash)
|
||||||
if video is None or video["duration_ms"] is None:
|
if video is None or video["duration_ms"] is None:
|
||||||
@@ -1221,3 +1279,22 @@ def _is_cjk_char(char: str) -> bool:
|
|||||||
or 0xF900 <= code <= 0xFAFF
|
or 0xF900 <= code <= 0xFAFF
|
||||||
or 0x20000 <= code <= 0x2CEAF
|
or 0x20000 <= code <= 0x2CEAF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _moss_segment_row(video_hash: str, index: int, segment: Dict[str, Any]) -> tuple:
|
||||||
|
start_seconds = float(segment["start_seconds"])
|
||||||
|
end_seconds = float(segment["end_seconds"])
|
||||||
|
words = segment.get("words")
|
||||||
|
words_json = json.dumps(words, ensure_ascii=False) if words else None
|
||||||
|
speaker = segment.get("speaker")
|
||||||
|
return (
|
||||||
|
video_hash,
|
||||||
|
index,
|
||||||
|
start_seconds,
|
||||||
|
end_seconds,
|
||||||
|
int(round(start_seconds * 1000)),
|
||||||
|
int(round(end_seconds * 1000)),
|
||||||
|
str(segment.get("text") or "").strip(),
|
||||||
|
str(speaker) if speaker is not None else None,
|
||||||
|
words_json,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import numpy as np
|
|||||||
|
|
||||||
from sentence_api.processing import (
|
from sentence_api.processing import (
|
||||||
MOSS_ALGORITHM_VERSION,
|
MOSS_ALGORITHM_VERSION,
|
||||||
|
VideoProcessor,
|
||||||
document_from_transcript,
|
document_from_transcript,
|
||||||
split_sentence_text,
|
split_sentence_text,
|
||||||
)
|
)
|
||||||
from sentence_api.models import SentenceBoundary
|
from sentence_api.models import SentenceBoundary, SentenceBoundaryDocument
|
||||||
|
from sentence_api.repository import VideoRepository
|
||||||
from sentence_api.transcription import (
|
from sentence_api.transcription import (
|
||||||
Transcript,
|
Transcript,
|
||||||
TranscriptionSegment,
|
TranscriptionSegment,
|
||||||
@@ -158,3 +160,166 @@ def test_split_sentence_text_uses_whisper_word_timestamps(tmp_path):
|
|||||||
("Alpha beta", 1000, 3000),
|
("Alpha beta", 1000, 3000),
|
||||||
("Gamma delta", 3000, 5000),
|
("Gamma delta", 3000, 5000),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_processor_persists_original_moss_segments(tmp_path, monkeypatch):
|
||||||
|
from sentence_api.config import Settings
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
|
||||||
|
settings.ensure_directories()
|
||||||
|
media_path = settings.videos_dir / f"{VIDEO_HASH}.mp4"
|
||||||
|
media_path.write_bytes(b"video")
|
||||||
|
repository = VideoRepository(settings.database_path)
|
||||||
|
repository.upsert_upload(
|
||||||
|
video_hash=VIDEO_HASH,
|
||||||
|
title="MOSS lesson",
|
||||||
|
filename="lesson.mp4",
|
||||||
|
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||||
|
content_type="video/mp4",
|
||||||
|
size_bytes=5,
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
transcript = Transcript(
|
||||||
|
text="Hello world. Next.",
|
||||||
|
segments=[
|
||||||
|
TranscriptionSegment(
|
||||||
|
start_seconds=0.0,
|
||||||
|
end_seconds=1.2,
|
||||||
|
text="Hello world.",
|
||||||
|
speaker="S01",
|
||||||
|
words=[WordTimestamp(0.0, 0.5, "Hello"), WordTimestamp(0.5, 1.2, "world.")],
|
||||||
|
),
|
||||||
|
TranscriptionSegment(
|
||||||
|
start_seconds=1.2,
|
||||||
|
end_seconds=2.4,
|
||||||
|
text="Next.",
|
||||||
|
speaker="S01",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
class AvailableTranscriber:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def transcribe(self, audio_path, language=None):
|
||||||
|
return transcript
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.processing._ensure_webm_cues_front", lambda path: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sentence_api.processing.extract_audio",
|
||||||
|
lambda src, dst: dst.write_bytes(b"RIFF"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("sentence_api.processing._media_duration_ms", lambda path: 2400)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sentence_api.processing.document_from_transcript",
|
||||||
|
lambda **kwargs: SentenceBoundaryDocument(
|
||||||
|
video_hash=kwargs["video_hash"],
|
||||||
|
duration_ms=2400,
|
||||||
|
algorithm_version=MOSS_ALGORITHM_VERSION,
|
||||||
|
sentences=[
|
||||||
|
SentenceBoundary(
|
||||||
|
index=0,
|
||||||
|
start_ms=0,
|
||||||
|
end_ms=1200,
|
||||||
|
text="Hello world.",
|
||||||
|
language="en",
|
||||||
|
reference_speech_duration_ms=1100,
|
||||||
|
),
|
||||||
|
SentenceBoundary(
|
||||||
|
index=1,
|
||||||
|
start_ms=1200,
|
||||||
|
end_ms=2400,
|
||||||
|
text="Next.",
|
||||||
|
language="en",
|
||||||
|
reference_speech_duration_ms=1100,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
VideoProcessor(settings, repository, AvailableTranscriber()).process(VIDEO_HASH)
|
||||||
|
|
||||||
|
segments = repository.get_moss_segments(VIDEO_HASH)
|
||||||
|
assert [(item["start_ms"], item["end_ms"], item["text"], item["speaker"]) for item in segments] == [
|
||||||
|
(0, 1200, "Hello world.", "S01"),
|
||||||
|
(1200, 2400, "Next.", "S01"),
|
||||||
|
]
|
||||||
|
assert segments[0]["words"] == [
|
||||||
|
{"start_seconds": 0.0, "end_seconds": 0.5, "text": "Hello"},
|
||||||
|
{"start_seconds": 0.5, "end_seconds": 1.2, "text": "world."},
|
||||||
|
]
|
||||||
|
assert segments[1]["words"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reprocess_without_moss_clears_original_segments(tmp_path, monkeypatch):
|
||||||
|
from sentence_api.config import Settings
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
|
||||||
|
settings.ensure_directories()
|
||||||
|
media_path = settings.videos_dir / f"{VIDEO_HASH}.mp4"
|
||||||
|
media_path.write_bytes(b"video")
|
||||||
|
repository = VideoRepository(settings.database_path)
|
||||||
|
repository.upsert_upload(
|
||||||
|
video_hash=VIDEO_HASH,
|
||||||
|
title="MOSS lesson",
|
||||||
|
filename="lesson.mp4",
|
||||||
|
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||||
|
content_type="video/mp4",
|
||||||
|
size_bytes=5,
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
repository.save_processing_result(
|
||||||
|
SentenceBoundaryDocument(
|
||||||
|
video_hash=VIDEO_HASH,
|
||||||
|
duration_ms=2400,
|
||||||
|
algorithm_version=MOSS_ALGORITHM_VERSION,
|
||||||
|
sentences=[
|
||||||
|
SentenceBoundary(
|
||||||
|
index=0,
|
||||||
|
start_ms=0,
|
||||||
|
end_ms=2400,
|
||||||
|
text="Hello.",
|
||||||
|
language="en",
|
||||||
|
reference_speech_duration_ms=2000,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"Hello.",
|
||||||
|
moss_segments=[
|
||||||
|
{
|
||||||
|
"start_seconds": 0.0,
|
||||||
|
"end_seconds": 2.4,
|
||||||
|
"text": "Hello.",
|
||||||
|
"speaker": "S01",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert repository.get_moss_segments(VIDEO_HASH)
|
||||||
|
|
||||||
|
class UnavailableTranscriber:
|
||||||
|
available = False
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.processing._ensure_webm_cues_front", lambda path: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sentence_api.processing.make_entry",
|
||||||
|
lambda *args, **kwargs: (
|
||||||
|
{
|
||||||
|
"duration_ms": 2400,
|
||||||
|
"sentences": [
|
||||||
|
SentenceBoundary(
|
||||||
|
index=0,
|
||||||
|
start_ms=0,
|
||||||
|
end_ms=2400,
|
||||||
|
text=None,
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
VideoProcessor(settings, repository, UnavailableTranscriber()).process(VIDEO_HASH)
|
||||||
|
assert repository.get_moss_segments(VIDEO_HASH) == []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user