add sentences borders enable in pc player

This commit is contained in:
2026-09-10 08:58:33 +08:00
parent 39896a5c24
commit 9980f78cfc
7 changed files with 556 additions and 23 deletions

View File

@@ -52,6 +52,7 @@ GET /api/v1/videos
GET /api/v1/videos/{sha256}
GET /api/v1/videos/{sha256}/content
GET /api/v1/videos/{sha256}/sentence-boundaries
POST /api/v1/audio/sentence-boundaries
POST /api/v1/sentences/explain
POST /api/v1/videos/{sha256}/sentences/{index}/assessments
@@ -67,6 +68,11 @@ DELETE /api/v1/admin/videos/{sha256}
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
authenticated client, generates boundaries in memory/temp storage only, and
returns a sentence document. It does not register or archive the uploaded
audio.
## AI Sentence Explanation
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.

View File

@@ -2,6 +2,7 @@ import hashlib
import hmac
import logging
import mimetypes
import tempfile
import uuid
import urllib.request
import urllib.error
@@ -57,7 +58,7 @@ from .models import (
VideoUploadResponse,
)
from .oss import VolcanoOSSUploader
from .processing import VideoProcessor, split_sentence_text
from .processing import VideoProcessor, generate_audio_boundary_document, split_sentence_text
from .repository import VideoRepository
from .store import BoundaryStore
from .transcription import MossTranscriber, Transcriber
@@ -72,6 +73,7 @@ SHA256_PATH = ApiPath(
)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
MAX_AUDIO_BOUNDARY_BYTES = 512 * 1024 * 1024
class UploadTooLargeError(ValueError):
@@ -329,6 +331,47 @@ def create_app(
raise HTTPException(status_code=503, detail=str(exc)) from exc
return SentenceExplanationResponse(explanation=explanation)
@application.post(
"/api/v1/audio/sentence-boundaries",
response_model=SentenceBoundaryDocument,
)
async def generate_sentence_boundaries(
file: UploadFile = File(...),
language: Optional[str] = Form(default=None, max_length=32),
user: AuthenticatedUser = Depends(current_user),
) -> SentenceBoundaryDocument:
del user
max_bytes = min(MAX_AUDIO_BOUNDARY_BYTES, service_settings.max_upload_bytes)
with tempfile.TemporaryDirectory(prefix="audio-boundaries-") as temp_dir:
audio_path = Path(temp_dir) / "audio.wav"
try:
audio_hash, _size = await run_in_threadpool(
_persist_upload,
file.file,
audio_path,
max_bytes,
)
document = await run_in_threadpool(
generate_audio_boundary_document,
video_hash=audio_hash,
audio_path=audio_path,
transcriber=moss,
language=language,
)
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Ad-hoc audio sentence boundary generation failed.")
raise HTTPException(
status_code=502,
detail="Sentence boundary generation failed. Please try again later.",
) from exc
finally:
await file.close()
return document
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
def unenroll_course(
video_hash: str = SHA256_PATH,

View File

@@ -14,7 +14,8 @@ from .audio_metrics import (
refine_sentence_start_ms,
)
from .config import Settings
from .generate_boundaries import ALGORITHM_VERSION, make_entry
from .generate_boundaries import ALGORITHM_VERSION, make_entry, media_duration_ms
from sentence_analysis import detect_sentence_boundaries
from .models import SentenceBoundary, SentenceBoundaryDocument
from .oss import VolcanoOSSUploader
from .repository import VideoRepository
@@ -27,6 +28,7 @@ from .transcription import (
logger = logging.getLogger(__name__)
MOSS_ALGORITHM_VERSION = "moss-punctuation-v3"
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
class VideoProcessor:
@@ -417,6 +419,67 @@ def document_from_transcript(
)
def generate_audio_boundary_document(
*,
video_hash: str,
audio_path: Path,
transcriber: Transcriber,
language: Optional[str] = None,
) -> SentenceBoundaryDocument:
"""Generate sentence boundaries for a client-supplied audio file.
The caller owns ``audio_path`` and is responsible for deleting it. Nothing
in this function copies or registers the audio in application storage.
"""
duration_ms = media_duration_ms(audio_path)
transcript = None
if transcriber.available:
try:
transcript = transcriber.transcribe(audio_path, language)
except Exception as exc:
logger.warning("Audio boundary transcription failed; using silence fallback: %s", exc)
if transcript is not None:
document = document_from_transcript(
video_hash=video_hash,
duration_ms=duration_ms,
transcript=transcript,
language=language,
audio_path=audio_path,
)
if document.sentences:
return document
starts = detect_sentence_boundaries(audio_path)
if not starts:
raise ValueError("No sentence boundaries could be detected in the audio.")
starts_ms = sorted({max(0, min(duration_ms - 1, int(round(start * 1000)))) for start in starts})
sentences: List[SentenceBoundary] = []
for index, start_ms in enumerate(starts_ms):
end_ms = starts_ms[index + 1] if index + 1 < len(starts_ms) else duration_ms
if end_ms <= start_ms:
continue
sentences.append(
SentenceBoundary(
index=len(sentences),
start_ms=start_ms,
end_ms=end_ms,
text=None,
language=language,
)
)
if not sentences:
raise ValueError("Detected audio boundaries do not form valid sentence ranges.")
return SentenceBoundaryDocument(
video_hash=video_hash,
duration_ms=duration_ms,
algorithm_version=SILENCE_ALGORITHM_VERSION,
sentences=sentences,
)
def _media_duration_ms(path: Path) -> int:
from .generate_boundaries import media_duration_ms

View File

@@ -80,6 +80,42 @@ def test_invalid_hash_is_rejected(tmp_path):
assert response.status_code == 422
def test_adhoc_audio_boundaries_do_not_persist_audio(tmp_path, monkeypatch):
client = make_client(tmp_path)
upload = tmp_path / "audio.wav"
upload.write_bytes(b"not-a-real-audio")
import sentence_api.processing as processing_module
monkeypatch.setattr(processing_module, "detect_sentence_boundaries", lambda path: [0.0, 1.0])
monkeypatch.setattr(processing_module, "media_duration_ms", lambda path: 2000)
response = client.post(
"/api/v1/audio/sentence-boundaries",
files={"file": ("audio.wav", upload.read_bytes(), "audio/wav")},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["algorithm_version"] == "silence-rms-v1"
assert payload["duration_ms"] == 2000
assert [(item["start_ms"], item["end_ms"]) for item in payload["sentences"]] == [(0, 1000), (1000, 2000)]
assert not (tmp_path / "data" / "work").exists() or not any((tmp_path / "data" / "work").iterdir())
assert not (tmp_path / "data" / "v").exists() or not any((tmp_path / "data" / "v").iterdir())
def test_adhoc_audio_boundaries_require_login(tmp_path):
client = make_client(tmp_path)
client.headers.clear()
response = client.post(
"/api/v1/audio/sentence-boundaries",
files={"file": ("audio.wav", b"audio", "audio/wav")},
)
assert response.status_code == 401
def test_sentence_explanation_requires_login(tmp_path):
index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8")