added share function
This commit is contained in:
@@ -22,8 +22,10 @@ import android.media.MediaMetadataRetriever
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.StrictMode
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
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.TrainingVideoSummary
|
||||||
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
|
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import org.json.JSONObject
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -95,6 +101,7 @@ class MainActivity : Activity() {
|
|||||||
private var recordingFile: File? = null
|
private var recordingFile: File? = null
|
||||||
private val dubSegments = mutableMapOf<Int, File>()
|
private val dubSegments = mutableMapOf<Int, File>()
|
||||||
private var mergedDubFile: File? = null
|
private var mergedDubFile: File? = null
|
||||||
|
private var dubbingStatusText: TextView? = null
|
||||||
private var dubbingPlayback = false
|
private var dubbingPlayback = false
|
||||||
|
|
||||||
private var activeItemId: String = ""
|
private var activeItemId: String = ""
|
||||||
@@ -712,6 +719,12 @@ class MainActivity : Activity() {
|
|||||||
setLineSpacing(4.dp.toFloat(), 1f)
|
setLineSpacing(4.dp.toFloat(), 1f)
|
||||||
setPadding(0, 8.dp, 0, 0)
|
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 ->
|
val sentenceButton = { label: String, action: () -> Unit ->
|
||||||
Button(this).apply {
|
Button(this).apply {
|
||||||
@@ -840,9 +853,14 @@ class MainActivity : Activity() {
|
|||||||
})
|
})
|
||||||
addView(scoreSummaryText)
|
addView(scoreSummaryText)
|
||||||
addView(scoreDetailText)
|
addView(scoreDetailText)
|
||||||
|
addView(dubbingStatusText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setDubbingStatus(message: String) {
|
||||||
|
dubbingStatusText?.text = message
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshTestUi() {
|
private fun refreshTestUi() {
|
||||||
if (!::testStatusText.isInitialized) {
|
if (!::testStatusText.isInitialized) {
|
||||||
return
|
return
|
||||||
@@ -862,6 +880,13 @@ class MainActivity : Activity() {
|
|||||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
|
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
|
||||||
}
|
}
|
||||||
refreshDubButtons()
|
refreshDubButtons()
|
||||||
|
if (mergedDubFile == null) {
|
||||||
|
dubbingStatusText?.text = when (dubSegments.size) {
|
||||||
|
0 -> "配音:请先录音"
|
||||||
|
1 -> "配音:已保存 1 句,可生成配音"
|
||||||
|
else -> "配音:已保存 ${dubSegments.size} 句,可生成配音"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyTestSubtitleVisibility() {
|
private fun applyTestSubtitleVisibility() {
|
||||||
@@ -976,7 +1001,7 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun refreshDubButtons() {
|
private fun refreshDubButtons() {
|
||||||
playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
|
playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
|
||||||
mergeDubButton.isEnabled = dubSegments.size >= 2 && !dubbingPlayback
|
mergeDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
|
||||||
shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
|
shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
|
||||||
val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] }
|
val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] }
|
||||||
playDubButton.text = when {
|
playDubButton.text = when {
|
||||||
@@ -1049,16 +1074,19 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeDubSegments() {
|
private fun mergeDubSegments() {
|
||||||
if (dubSegments.size < 2) {
|
val segments = dubSegments.toSortedMap().values.toList()
|
||||||
|
if (segments.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
|
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
|
||||||
|
var muxer: MediaMuxer? = null
|
||||||
try {
|
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 muxerAudioTrack = -1
|
||||||
|
var muxerStarted = false
|
||||||
var presentationTimeUs = 0L
|
var presentationTimeUs = 0L
|
||||||
|
|
||||||
dubSegments.toSortedMap().values.forEach { segment ->
|
segments.forEach { segment ->
|
||||||
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
|
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
|
||||||
try {
|
try {
|
||||||
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
|
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
|
||||||
@@ -1069,6 +1097,8 @@ class MainActivity : Activity() {
|
|||||||
val format = extractor.getTrackFormat(trackIndex)
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
if (muxerAudioTrack == -1) {
|
if (muxerAudioTrack == -1) {
|
||||||
muxerAudioTrack = muxer.addTrack(format)
|
muxerAudioTrack = muxer.addTrack(format)
|
||||||
|
muxer.start()
|
||||||
|
muxerStarted = true
|
||||||
}
|
}
|
||||||
val buffer = ByteBuffer.allocateDirect(
|
val buffer = ByteBuffer.allocateDirect(
|
||||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||||
@@ -1093,14 +1123,24 @@ class MainActivity : Activity() {
|
|||||||
extractor.release()
|
extractor.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
require(muxerStarted && muxerAudioTrack != -1) { "录音没有可用音频轨道" }
|
||||||
muxer.stop()
|
muxer.stop()
|
||||||
muxer.release()
|
muxer.release()
|
||||||
|
muxer = null
|
||||||
mergedDubFile = output
|
mergedDubFile = output
|
||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
statusText.text = "已合成 ${dubSegments.size} 段配音:${output.name}"
|
setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”")
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
|
runCatching { muxer?.stop() }
|
||||||
|
runCatching { muxer?.release() }
|
||||||
output.delete()
|
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() {
|
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 {
|
try {
|
||||||
val uri = FileProvider.getUriForFile(
|
val shareUrl = uploadDubShare(item.id, item.title)
|
||||||
this,
|
|
||||||
"${packageName}.fileprovider",
|
|
||||||
file,
|
|
||||||
)
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "audio/mp4"
|
type = "text/plain"
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
|
||||||
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
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, "分享配音"))
|
startActivity(Intent.createChooser(intent, "分享配音"))
|
||||||
|
setDubbingStatus("已生成分享链接:$shareUrl")
|
||||||
} catch (error: Throwable) {
|
} 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 PICK_VIDEO_REQUEST = 1001
|
||||||
const val RECORD_AUDIO_REQUEST = 1002
|
const val RECORD_AUDIO_REQUEST = 1002
|
||||||
const val PROGRESS_MAX = 1000
|
const val PROGRESS_MAX = 1000
|
||||||
|
private const val TAG_DUBBING = "Dubbing"
|
||||||
|
|
||||||
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
||||||
|
|
||||||
|
|||||||
@@ -68,10 +68,14 @@ class Settings:
|
|||||||
def attempts_dir(self) -> Path:
|
def attempts_dir(self) -> Path:
|
||||||
return self.data_dir / "attempts"
|
return self.data_dir / "attempts"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dub_shares_dir(self) -> Path:
|
||||||
|
return self.data_dir / "dub_shares"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
return self.data_dir / "oral_trainer.sqlite3"
|
return self.data_dir / "oral_trainer.sqlite3"
|
||||||
|
|
||||||
def ensure_directories(self) -> None:
|
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)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, BinaryIO, Dict, Optional
|
from typing import Any, BinaryIO, Dict, List, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
BackgroundTasks,
|
BackgroundTasks,
|
||||||
@@ -51,6 +52,7 @@ SHA256_PATH = ApiPath(
|
|||||||
description="SHA-256 hex digest of the exact uploaded video bytes",
|
description="SHA-256 hex digest of the exact uploaded video bytes",
|
||||||
)
|
)
|
||||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
|
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
|
||||||
|
|
||||||
|
|
||||||
class UploadTooLargeError(ValueError):
|
class UploadTooLargeError(ValueError):
|
||||||
@@ -380,6 +382,116 @@ def create_app(
|
|||||||
if not service_settings.keep_attempt_audio or not completed:
|
if not service_settings.keep_attempt_audio or not completed:
|
||||||
temporary_path.unlink(missing_ok=True)
|
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
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||||
from .store import normalize_video_hash
|
from .store import normalize_video_hash
|
||||||
@@ -67,6 +68,27 @@ class VideoRepository:
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
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"],
|
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(
|
def record_attempt(
|
||||||
self,
|
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