added share function

This commit is contained in:
2026-08-26 09:47:52 +08:00
parent ccd8ec16bb
commit f80da8a5f6
5 changed files with 435 additions and 18 deletions

View File

@@ -22,8 +22,10 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.StrictMode
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
@@ -57,8 +59,12 @@ import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
import java.io.File
import java.io.DataOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.util.Locale
import org.json.JSONObject
import androidx.core.content.FileProvider
import kotlin.math.max
import androidx.media3.common.MediaItem
@@ -95,6 +101,7 @@ class MainActivity : Activity() {
private var recordingFile: File? = null
private val dubSegments = mutableMapOf<Int, File>()
private var mergedDubFile: File? = null
private var dubbingStatusText: TextView? = null
private var dubbingPlayback = false
private var activeItemId: String = ""
@@ -712,6 +719,12 @@ class MainActivity : Activity() {
setLineSpacing(4.dp.toFloat(), 1f)
setPadding(0, 8.dp, 0, 0)
}
dubbingStatusText = TextView(this).apply {
setTextColor(COLOR_ACCENT_DEEP)
textSize = 13f
setPadding(0, 10.dp, 0, 0)
text = "配音:尚未生成"
}
val sentenceButton = { label: String, action: () -> Unit ->
Button(this).apply {
@@ -840,9 +853,14 @@ class MainActivity : Activity() {
})
addView(scoreSummaryText)
addView(scoreDetailText)
addView(dubbingStatusText)
}
}
private fun setDubbingStatus(message: String) {
dubbingStatusText?.text = message
}
private fun refreshTestUi() {
if (!::testStatusText.isInitialized) {
return
@@ -862,6 +880,13 @@ class MainActivity : Activity() {
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
}
refreshDubButtons()
if (mergedDubFile == null) {
dubbingStatusText?.text = when (dubSegments.size) {
0 -> "配音:请先录音"
1 -> "配音:已保存 1 句,可生成配音"
else -> "配音:已保存 ${dubSegments.size} 句,可生成配音"
}
}
}
private fun applyTestSubtitleVisibility() {
@@ -976,7 +1001,7 @@ class MainActivity : Activity() {
private fun refreshDubButtons() {
playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
mergeDubButton.isEnabled = dubSegments.size >= 2 && !dubbingPlayback
mergeDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] }
playDubButton.text = when {
@@ -1049,16 +1074,19 @@ class MainActivity : Activity() {
}
private fun mergeDubSegments() {
if (dubSegments.size < 2) {
val segments = dubSegments.toSortedMap().values.toList()
if (segments.isEmpty()) {
return
}
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
var muxer: MediaMuxer? = null
try {
val muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var muxerAudioTrack = -1
var muxerStarted = false
var presentationTimeUs = 0L
dubSegments.toSortedMap().values.forEach { segment ->
segments.forEach { segment ->
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
try {
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
@@ -1069,6 +1097,8 @@ class MainActivity : Activity() {
val format = extractor.getTrackFormat(trackIndex)
if (muxerAudioTrack == -1) {
muxerAudioTrack = muxer.addTrack(format)
muxer.start()
muxerStarted = true
}
val buffer = ByteBuffer.allocateDirect(
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
@@ -1093,14 +1123,24 @@ class MainActivity : Activity() {
extractor.release()
}
}
require(muxerStarted && muxerAudioTrack != -1) { "录音没有可用音频轨道" }
muxer.stop()
muxer.release()
muxer = null
mergedDubFile = output
refreshTestUi()
statusText.text = "已合成 ${dubSegments.size} 配音${output.name}"
setDubbingStatus("已合成 ${segments.size} 配音,可点击“分享配音”")
} catch (error: Throwable) {
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
output.delete()
statusText.text = "合成失败:${error.message.orEmpty()}"
Log.e(TAG_DUBBING, "merge failed", error)
setDubbingStatus(
buildString {
append("生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
append(" · 已保存 ${dubSegments.size}")
}
)
}
}
@@ -1120,22 +1160,74 @@ class MainActivity : Activity() {
}
private fun shareMergedDubbing() {
val file = mergedDubFile?.takeIf { it.exists() } ?: return
val item = controller.currentTrainingItem()
if (item == null || !SHA256_PATTERN.matches(item.id)) {
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
return
}
try {
val uri = FileProvider.getUriForFile(
this,
"${packageName}.fileprovider",
file,
)
val shareUrl = uploadDubShare(item.id, item.title)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "audio/mp4"
putExtra(Intent.EXTRA_STREAM, uri)
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(Intent.createChooser(intent, "分享配音"))
setDubbingStatus("已生成分享链接:$shareUrl")
} catch (error: Throwable) {
statusText.text = "分享失败:${error.message.orEmpty()}"
Log.e(TAG_DUBBING, "share failed", error)
setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
}
}
private fun uploadDubShare(videoHash: String, videoTitle: String): String {
val policy = StrictMode.getThreadPolicy()
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
val boundary = "oral-trainer-${System.currentTimeMillis()}"
var connection: HttpURLConnection? = null
try {
connection = URL(URL(SERVER_BASE_URL), "/api/v1/dub-shares").openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
DataOutputStream(connection.outputStream).use { output ->
fun text(name: String, value: String) {
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
}
text("video_hash", videoHash.lowercase(Locale.US))
text("title", videoTitle.ifBlank { "我的口语配音" })
// Build explicitly to avoid fragile string replacement.
val payload = dubSegments.keys.sorted().joinToString(",", "[", "]") {
"{\"sentence_index\":$it}"
}
text("segments", payload)
dubSegments.toSortedMap().forEach { (index, file) ->
output.writeBytes("--$boundary\r\n")
output.writeBytes(
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
)
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
file.inputStream().use { input -> input.copyTo(output) }
output.writeBytes("\r\n")
}
output.writeBytes("--$boundary--\r\n")
}
val code = connection.responseCode
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
require(code in 200..299) { "HTTP $code: $body" }
val response = JSONObject(body).getString("share_id")
return "$SERVER_BASE_URL/dub-shares/$response"
} finally {
connection?.disconnect()
StrictMode.setThreadPolicy(policy)
}
}
@@ -1511,6 +1603,7 @@ class MainActivity : Activity() {
const val PICK_VIDEO_REQUEST = 1001
const val RECORD_AUDIO_REQUEST = 1002
const val PROGRESS_MAX = 1000
private const val TAG_DUBBING = "Dubbing"
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
*,

View 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>