added share function
This commit is contained in:
@@ -68,10 +68,14 @@ class Settings:
|
||||
def attempts_dir(self) -> Path:
|
||||
return self.data_dir / "attempts"
|
||||
|
||||
@property
|
||||
def dub_shares_dir(self) -> Path:
|
||||
return self.data_dir / "dub_shares"
|
||||
|
||||
@property
|
||||
def database_path(self) -> Path:
|
||||
return self.data_dir / "oral_trainer.sqlite3"
|
||||
|
||||
def ensure_directories(self) -> None:
|
||||
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir):
|
||||
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir, self.dub_shares_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -4,7 +4,8 @@ import logging
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, BinaryIO, Dict, Optional
|
||||
from typing import Any, BinaryIO, Dict, List, Optional
|
||||
import json
|
||||
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
@@ -51,6 +52,7 @@ SHA256_PATH = ApiPath(
|
||||
description="SHA-256 hex digest of the exact uploaded video bytes",
|
||||
)
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
|
||||
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
|
||||
|
||||
|
||||
class UploadTooLargeError(ValueError):
|
||||
@@ -380,6 +382,116 @@ def create_app(
|
||||
if not service_settings.keep_attempt_audio or not completed:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
@application.post("/api/v1/dub-shares", status_code=201)
|
||||
async def create_dub_share(
|
||||
request: Request,
|
||||
video_hash: str = Form(...),
|
||||
title: str = Form(default="我的口语配音"),
|
||||
segments: str = Form(...),
|
||||
files: List[UploadFile] = File(...),
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
segment_items = json.loads(segments)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="segments must be valid JSON.") from exc
|
||||
if not isinstance(segment_items, list) or not segment_items:
|
||||
raise HTTPException(status_code=400, detail="At least one dubbing segment is required.")
|
||||
if len(segment_items) != len(files):
|
||||
raise HTTPException(status_code=400, detail="Segment count does not match audio file count.")
|
||||
document = find_document(video_hash.lower())
|
||||
if document is None:
|
||||
raise HTTPException(status_code=404, detail="Video or sentence boundaries were not found.")
|
||||
|
||||
prepared_segments: List[Dict[str, Any]] = []
|
||||
saved_files: List[Path] = []
|
||||
try:
|
||||
for item, upload in zip(segment_items, files):
|
||||
sentence_index = int(item["sentence_index"])
|
||||
boundary = next((s for s in document.sentences if s.index == sentence_index), None)
|
||||
if boundary is None:
|
||||
raise HTTPException(status_code=404, detail=f"Sentence {sentence_index} was not found.")
|
||||
suffix = Path(upload.filename or "dub.m4a").suffix.lower()
|
||||
if suffix == ".mp4":
|
||||
suffix = ".m4a"
|
||||
if suffix not in ALLOWED_AUDIO_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail=f"Unsupported audio extension: {suffix or '(none)'}")
|
||||
audio_name = f"{uuid.uuid4().hex}{suffix}"
|
||||
audio_path = service_settings.dub_shares_dir / audio_name
|
||||
await run_in_threadpool(_persist_upload, upload.file, audio_path, 50 * 1024 * 1024)
|
||||
saved_files.append(audio_path)
|
||||
prepared_segments.append({
|
||||
"sentence_index": sentence_index,
|
||||
"start_ms": boundary.start_ms,
|
||||
"end_ms": boundary.end_ms,
|
||||
"text": boundary.text,
|
||||
"audio_filename": audio_name,
|
||||
"audio_size_bytes": audio_path.stat().st_size,
|
||||
})
|
||||
share = video_repository.create_dub_share(
|
||||
video_hash=video_hash.lower(),
|
||||
title=title,
|
||||
segments=prepared_segments,
|
||||
)
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Video was not found.")
|
||||
return {
|
||||
**share,
|
||||
"share_url": f"/dub-shares/{share['share_id']}",
|
||||
}
|
||||
except UploadTooLargeError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
finally:
|
||||
for upload in files:
|
||||
await upload.close()
|
||||
# Files intentionally remain until share deletion because the web page references them.
|
||||
del saved_files
|
||||
|
||||
@application.get("/api/v1/dub-shares/{share_id}")
|
||||
def get_dub_share(share_id: str) -> Dict[str, Any]:
|
||||
share = video_repository.get_dub_share(share_id)
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
|
||||
return {
|
||||
"share_id": share["share_id"],
|
||||
"title": share["title"],
|
||||
"created_at": share["created_at"],
|
||||
"segments": [
|
||||
{
|
||||
"index": row["sentence_index"],
|
||||
"start_ms": row["start_ms"],
|
||||
"end_ms": row["end_ms"],
|
||||
"text": row["text"],
|
||||
"audio_url": f"/api/v1/dub-shares/{share_id}/audio/{row['audio_filename']}",
|
||||
}
|
||||
for row in share["segments"]
|
||||
],
|
||||
}
|
||||
|
||||
@application.get("/api/v1/dub-shares/{share_id}/audio/{filename}")
|
||||
def get_dub_share_audio(share_id: str, filename: str) -> FileResponse:
|
||||
share = video_repository.get_dub_share(share_id)
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
|
||||
safe_name = Path(filename).name
|
||||
row = next((item for item in share["segments"] if item["audio_filename"] == safe_name), None)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Audio was not found.")
|
||||
media_path = service_settings.dub_shares_dir / safe_name
|
||||
if not media_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Stored audio file is missing.")
|
||||
return FileResponse(media_path, media_type="audio/mp4", headers={"Cache-Control": "public, max-age=31536000"})
|
||||
|
||||
@application.get("/dub-shares/{share_id}", include_in_schema=False)
|
||||
def dub_share_page(share_id: str) -> FileResponse:
|
||||
if video_repository.get_dub_share(share_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
|
||||
page = static_dir / "dub-share.html"
|
||||
if not page.is_file():
|
||||
raise HTTPException(status_code=404, detail="Dubbing share UI is not installed.")
|
||||
return FileResponse(page)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
|
||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||
from .store import normalize_video_hash
|
||||
@@ -67,6 +68,27 @@ class VideoRepository:
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dub_shares (
|
||||
share_id TEXT PRIMARY KEY,
|
||||
video_hash TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dub_share_segments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
sentence_index INTEGER NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
text TEXT,
|
||||
audio_filename TEXT NOT NULL,
|
||||
audio_size_bytes INTEGER NOT NULL,
|
||||
FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE,
|
||||
UNIQUE (share_id, sentence_index)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -266,6 +288,72 @@ class VideoRepository:
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
|
||||
def create_dub_share(
|
||||
self,
|
||||
*,
|
||||
video_hash: str,
|
||||
title: str,
|
||||
segments: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
normalized_hash = normalize_video_hash(video_hash)
|
||||
if self.get_video(normalized_hash) is None:
|
||||
return None
|
||||
share_id = uuid.uuid4().hex
|
||||
now = utc_now()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO dub_shares (share_id, video_hash, title, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(share_id, normalized_hash, title[:200], now),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO dub_share_segments (
|
||||
share_id, sentence_index, start_ms, end_ms, text,
|
||||
audio_filename, audio_size_bytes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
share_id,
|
||||
segment["sentence_index"],
|
||||
segment["start_ms"],
|
||||
segment["end_ms"],
|
||||
segment["text"],
|
||||
segment["audio_filename"],
|
||||
segment["audio_size_bytes"],
|
||||
)
|
||||
for segment in segments
|
||||
],
|
||||
)
|
||||
return {
|
||||
"share_id": share_id,
|
||||
"video_hash": normalized_hash,
|
||||
"title": title[:200],
|
||||
"created_at": now,
|
||||
}
|
||||
|
||||
def get_dub_share(self, share_id: str) -> Optional[Dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
share = connection.execute(
|
||||
"SELECT * FROM dub_shares WHERE share_id = ?", (share_id,)
|
||||
).fetchone()
|
||||
if share is None:
|
||||
return None
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT * FROM dub_share_segments
|
||||
WHERE share_id = ?
|
||||
ORDER BY sentence_index
|
||||
""",
|
||||
(share_id,),
|
||||
).fetchall()
|
||||
result = dict(share)
|
||||
result["segments"] = [dict(row) for row in rows]
|
||||
return result
|
||||
|
||||
def record_attempt(
|
||||
self,
|
||||
*,
|
||||
|
||||
120
sentence_api/static/dub-share.html
Normal file
120
sentence_api/static/dub-share.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>口语配音</title>
|
||||
<style>
|
||||
body{margin:0;background:#0b0f13;color:#e7edf3;font-family:-apple-system,BlinkMacSystemFont,sans-serif}
|
||||
.wrap{max-width:900px;margin:0 auto;padding:20px}
|
||||
video{width:100%;border-radius:12px;background:#000;aspect-ratio:16/9}
|
||||
h1{font-size:22px}.muted{color:#94a3b8;font-size:14px}
|
||||
.controls{display:flex;gap:10px;margin:14px 0}
|
||||
button{flex:1;height:44px;border:0;border-radius:10px;color:#fff;background:#2563eb;font-weight:700;font-size:15px}
|
||||
.active{background:#14b8a6}
|
||||
.sentences{display:flex;flex-direction:column;gap:8px}
|
||||
.sentence{padding:12px;border-radius:10px;background:#1c2228;color:#cbd5e1;line-height:1.5}
|
||||
.active-sentence{outline:2px solid #14b8a6;color:#fff}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<h1 id="title">加载中…</h1>
|
||||
<p class="muted" id="status">正在读取配音数据</p>
|
||||
<video id="video" playsinline controls></video>
|
||||
<div class="controls">
|
||||
<button id="original">播放原音</button>
|
||||
<button id="dubbing">播放配音</button>
|
||||
</div>
|
||||
<div class="sentences" id="sentences"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const video = document.getElementById('video');
|
||||
const originalButton = document.getElementById('original');
|
||||
const dubbingButton = document.getElementById('dubbing');
|
||||
const statusText = document.getElementById('status');
|
||||
const sentenceList = document.getElementById('sentences');
|
||||
let dubAudio = new Audio();
|
||||
dubAudio.preload = 'auto';
|
||||
let segments = [];
|
||||
let mode = null;
|
||||
|
||||
function stopAll() {
|
||||
video.pause();
|
||||
dubAudio.pause();
|
||||
originalButton.classList.remove('active');
|
||||
dubbingButton.classList.remove('active');
|
||||
mode = null;
|
||||
}
|
||||
|
||||
function playOriginal() {
|
||||
stopAll();
|
||||
mode = 'original';
|
||||
originalButton.classList.add('active');
|
||||
video.muted = false;
|
||||
video.play();
|
||||
}
|
||||
|
||||
function syncDub() {
|
||||
const timeMs = video.currentTime * 1000;
|
||||
const active = segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms);
|
||||
document.querySelectorAll('.sentence').forEach((node, index) => {
|
||||
node.classList.toggle('active-sentence', segments[index] === active);
|
||||
});
|
||||
if (mode !== 'dubbing') return;
|
||||
if (!active) {
|
||||
dubAudio.pause();
|
||||
return;
|
||||
}
|
||||
const source = active.audio_url;
|
||||
if (dubAudio.dataset.src !== source) {
|
||||
dubAudio.dataset.src = source;
|
||||
dubAudio.src = source;
|
||||
}
|
||||
const target = Math.max(0, timeMs - active.start_ms) / 1000;
|
||||
if (Math.abs(dubAudio.currentTime - target) > 0.25 && Number.isFinite(target)) {
|
||||
dubAudio.currentTime = target;
|
||||
}
|
||||
if (video.paused || dubAudio.paused) dubAudio.play().catch(() => {});
|
||||
}
|
||||
|
||||
function playDubbing() {
|
||||
stopAll();
|
||||
mode = 'dubbing';
|
||||
dubbingButton.classList.add('active');
|
||||
video.muted = true;
|
||||
video.play();
|
||||
syncDub();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const shareId = location.pathname.split('/').pop();
|
||||
const shareResponse = await fetch(`/api/v1/dub-shares/${shareId}`);
|
||||
if (!shareResponse.ok) throw new Error(`HTTP ${shareResponse.status}`);
|
||||
const share = await shareResponse.json();
|
||||
segments = share.segments;
|
||||
const detailResponse = await fetch(`/api/v1/videos/${share.video_hash}`);
|
||||
const detail = await detailResponse.json();
|
||||
video.src = detail.video.stream_url;
|
||||
document.title = share.title;
|
||||
document.getElementById('title').textContent = share.title;
|
||||
statusText.textContent = `共 ${segments.length} 句配音`;
|
||||
segments.forEach(item => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'sentence';
|
||||
node.textContent = item.text || `第 ${item.index + 1} 句`;
|
||||
node.onclick = () => { video.currentTime = item.start_ms / 1000; };
|
||||
sentenceList.appendChild(node);
|
||||
});
|
||||
originalButton.onclick = playOriginal;
|
||||
dubbingButton.onclick = playDubbing;
|
||||
video.addEventListener('timeupdate', syncDub);
|
||||
video.addEventListener('play', () => { if (mode === 'dubbing') syncDub(); });
|
||||
video.addEventListener('pause', () => dubAudio.pause());
|
||||
video.addEventListener('seeked', syncDub);
|
||||
} catch (error) {
|
||||
statusText.textContent = `加载失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
Reference in New Issue
Block a user