From 031f3c6f2171d53b0e02401be852605504a382f6 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Thu, 29 Oct 2026 08:48:49 +0800 Subject: [PATCH] later process --- android/sample-app/build.gradle.kts | 2 +- .../UserInterfaceState.xcuserstate | Bin 28233 -> 27921 bytes sentence_api/DEPLOYMENT.md | 5 +- sentence_api/README.md | 1 + sentence_api/main.py | 139 +++++++ sentence_api/models.py | 28 ++ sentence_api/static/admin.html | 4 +- sentence_api/static/admin.js | 344 ++++++++++++++---- sentence_api/tests/test_api.py | 103 ++++++ 9 files changed, 541 insertions(+), 85 deletions(-) diff --git a/android/sample-app/build.gradle.kts b/android/sample-app/build.gradle.kts index 1f864fd..5e0b6f5 100644 --- a/android/sample-app/build.gradle.kts +++ b/android/sample-app/build.gradle.kts @@ -19,7 +19,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "0.1.5" + versionName = "0.1.6" } compileOptions { diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 3b3d8e5c2605afee4d2b6ad9ab09aa67bbd12592..393329792a66c3adffc8ef6dc6b8931dee6dd7aa 100644 GIT binary patch delta 12700 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 List[SentenceBoundary]: + try: + result = _replace_admin_sentences( + repository=video_repository, + transcriber=moss, + settings=service_settings, + video_hash=video_hash, + payload=payload, + ) + except KeyError: + raise HTTPException(status_code=404, detail="Course is not available.") from None + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + application.state.subtitle_translations.refresh(video_hash) + return result + @application.put( "/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}", response_model=SentenceBoundary, @@ -1202,6 +1228,119 @@ def _process_safely(processor: VideoProcessor, video_hash: str) -> None: logger.exception("Video processing failed for %s", video_hash) +def _replace_admin_sentences( + *, + repository: VideoRepository, + transcriber: Transcriber, + settings: Settings, + video_hash: str, + payload: AdminSentenceBatchUpdate, +) -> List[SentenceBoundary]: + """Apply queued admin edits once, instead of after every split/merge.""" + video = repository.get_video(video_hash) + old_document = repository.get_document(video_hash) + if video is None or old_document is None: + raise KeyError(video_hash) + + old_sentences = { + (sentence.start_ms, sentence.end_ms): sentence + for sentence in old_document.sentences + } + rows: List[SentenceBoundary] = [] + index = 0 + position = 0 + while position < len(payload.sentences): + item = payload.sentences[position] + if item.source_start_ms is None or item.source_end_ms is None: + if "\n" in item.text or "\r" in item.text: + raise ValueError("Unsplit batch sentences cannot contain line breaks.") + rows.append( + SentenceBoundary( + index=index, + start_ms=item.start_ms, + end_ms=item.end_ms, + text=item.text.strip(), + language=item.language, + reference_speech_duration_ms=item.reference_speech_duration_ms, + ) + ) + index += 1 + position += 1 + continue + + source_key = (item.source_start_ms, item.source_end_ms) + group_end = position + 1 + while group_end < len(payload.sentences): + following = payload.sentences[group_end] + if following.source_start_ms != source_key[0] or following.source_end_ms != source_key[1]: + break + group_end += 1 + + group = payload.sentences[position:group_end] + source = old_sentences.get(source_key) + if source is None: + raise ValueError("The original sentence for a queued split no longer exists.") + if len(group) == 1: + if "\n" in group[0].text or "\r" in group[0].text: + raise ValueError("Unsplit batch sentences cannot contain line breaks.") + rows.append( + SentenceBoundary( + index=index, + start_ms=group[0].start_ms, + end_ms=group[0].end_ms, + text=group[0].text.strip(), + language=group[0].language, + reference_speech_duration_ms=group[0].reference_speech_duration_ms, + ) + ) + else: + split_text = "\n".join(item.text.strip() for item in group if item.text.strip()) + replacements = split_sentence_text( + transcriber=transcriber, + media_path=settings.videos_dir / video["stored_filename"], + sentence=source, + text=split_text, + language=next( + (item.language for item in group if item.language is not None), + source.language, + ), + ) + if len(replacements) != len(group): + raise ValueError("Split result count does not match the queued sentences.") + for replacement, item in zip(replacements, group): + rows.append( + SentenceBoundary( + index=index, + start_ms=replacement.start_ms, + end_ms=replacement.end_ms, + text=item.text.strip(), + language=( + item.language + if item.language is not None + else source.language + ), + reference_speech_duration_ms=replacement.reference_speech_duration_ms, + ) + ) + index += 1 + index = len(rows) + position = group_end + + duration_ms = video["duration_ms"] + if duration_ms is None: + raise ValueError("Course duration is not available.") + previous_end = 0 + for row in rows: + if row.start_ms < previous_end: + raise ValueError("Batch sentences must be sorted and non-overlapping.") + if row.end_ms > duration_ms: + raise ValueError("Batch sentences cannot exceed the course duration.") + previous_end = row.end_ms + + document = old_document.model_copy(update={"sentences": rows}) + return repository.replace_sentences(document).sentences + + def _video_summary(row: Dict[str, Any], settings: Settings) -> VideoSummary: if row.get("remote_url"): stream_url = row["remote_url"] diff --git a/sentence_api/models.py b/sentence_api/models.py index a1b7d1c..7d9e43f 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -93,6 +93,34 @@ class SentenceTextUpdate(BaseModel): language: Optional[str] = Field(default=None, max_length=32) +class AdminSentenceBatchItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + start_ms: int = Field(ge=0) + end_ms: int = Field(gt=0) + text: str = Field(min_length=1, max_length=4000) + language: Optional[str] = Field(default=None, max_length=32) + reference_speech_duration_ms: int = Field(gt=0) + source_start_ms: Optional[int] = Field(default=None, ge=0) + source_end_ms: Optional[int] = Field(default=None, gt=0) + + @model_validator(mode="after") + def validate_range(self): + if self.end_ms <= self.start_ms: + raise ValueError("end_ms must be greater than start_ms") + if (self.source_start_ms is None) != (self.source_end_ms is None): + raise ValueError("source_start_ms and source_end_ms must be set together") + elif self.source_start_ms is not None and self.source_end_ms <= self.source_start_ms: + raise ValueError("source end_ms must be greater than source start_ms") + return self + + +class AdminSentenceBatchUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + sentences: List[AdminSentenceBatchItem] = Field(min_length=1, max_length=10000) + + class CourseTitleUpdate(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 20690e3..d878057 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -126,7 +126,7 @@
- +
@@ -139,6 +139,6 @@
- + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index b3c8ea4..73711d9 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -7,6 +7,10 @@ const state = { users: [], retranslateVideo: null, sentenceVideo: null, + sentenceDraft: null, + sentenceBaseMeta: "", + sentencePending: false, + sentenceSaving: false, }; const elements = { @@ -52,10 +56,19 @@ elements.refresh.addEventListener("click", () => { loadUsers(); }); elements.closeDialog.addEventListener("click", () => elements.dialog.close()); +elements.dialog.addEventListener("close", handleSentenceDialogClose); elements.repairGaps.addEventListener("click", () => { + if (state.sentencePending) { + elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理"; + return; + } if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true }); }); elements.repairMissingZh.addEventListener("click", () => { + if (state.sentencePending) { + elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理"; + return; + } if (state.sentenceVideo) repairMissingTranslations(state.sentenceVideo, { reloadSentences: true }); }); elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close()); @@ -297,37 +310,218 @@ function readXhrError(request) { } async function openSentences(video) { + if (state.sentenceSaving) { + elements.serviceState.textContent = "句子批量处理中,请稍后再打开"; + return; + } + if ( + state.sentenceVideo?.video_hash === video.video_hash && + Array.isArray(state.sentenceDraft) + ) { + renderSentenceDraft(video); + if (!elements.dialog.open) elements.dialog.showModal(); + return; + } try { const detail = await api(`/api/v1/videos/${video.video_hash}`); state.sentenceVideo = video; elements.sentenceTitle.textContent = detail.video.title; - const sentences = detail.boundaries?.sentences || []; + state.sentenceDraft = (detail.boundaries?.sentences || []).map((sentence) => ({ + ...sentence, + originStartMs: sentence.start_ms, + originEndMs: sentence.end_ms, + sourceStartMs: undefined, + sourceEndMs: undefined, + })); + state.sentencePending = false; + state.sentenceSaving = false; const durationMs = detail.video.duration_ms || 0; + const sentences = state.sentenceDraft; const gaps = collectSentenceGaps(sentences, durationMs); const missingZh = sentences.filter(translationNeedsRepair).length; const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : ""; const zhLabel = missingZh ? ` / ${missingZh} 句未译` : ""; - elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`; - elements.repairGaps.disabled = gaps.length === 0; - elements.repairMissingZh.disabled = missingZh === 0; - elements.sentenceRows.replaceChildren(); - let gapIndex = 0; - for (const sentence of sentences) { - if (gapIndex < gaps.length && gaps[gapIndex].end_ms === sentence.start_ms) { - elements.sentenceRows.append(gapRow(gaps[gapIndex])); - gapIndex += 1; - } - elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length)); - } - if (gapIndex < gaps.length) { - elements.sentenceRows.append(gapRow(gaps[gapIndex])); - } + state.sentenceBaseMeta = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`; + renderSentenceDraft(video); if (!elements.dialog.open) elements.dialog.showModal(); } catch (error) { elements.serviceState.textContent = `句子加载失败: ${error.message}`; } } +function renderSentenceDraft(video) { + const sentences = state.sentenceDraft || []; + const durationMs = state.sentenceVideo?.duration_ms || 0; + const gaps = collectSentenceGaps(sentences, durationMs); + const missingZh = sentences.filter(translationNeedsRepair).length; + elements.sentenceMeta.textContent = [ + state.sentenceBaseMeta, + state.sentencePending ? "有暂存修改" : "", + state.sentenceSaving ? "正在处理" : "", + ].filter(Boolean).join(" / "); + elements.repairGaps.disabled = gaps.length === 0 || state.sentencePending; + elements.repairMissingZh.disabled = missingZh === 0 || state.sentencePending; + elements.closeDialog.disabled = state.sentenceSaving; + elements.closeDialog.textContent = state.sentenceSaving ? "..." : "X"; + elements.sentenceRows.replaceChildren(); + let gapIndex = 0; + for (const sentence of sentences) { + if (gapIndex < gaps.length && gaps[gapIndex].end_ms === sentence.start_ms) { + elements.sentenceRows.append(gapRow(gaps[gapIndex])); + gapIndex += 1; + } + elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length)); + } + if (gapIndex < gaps.length) { + elements.sentenceRows.append(gapRow(gaps[gapIndex])); + } +} + +async function handleSentenceDialogClose() { + if (!state.sentenceDraft || state.sentenceSaving) return; + if (!state.sentencePending) { + resetSentenceDraft(); + return; + } + const saved = await flushSentenceDraft(); + if (!saved) { + if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal(); + return; + } + resetSentenceDraft(); + loadVideos(); +} + +async function flushSentenceDraft() { + const video = state.sentenceVideo; + if (!video || !state.sentencePending) return true; + materializeSentenceDraftSplits(video); + state.sentenceSaving = true; + renderSentenceDraft(video); + try { + const body = { + sentences: state.sentenceDraft.map(({ index, zh_text, ...sentence }) => sentence), + }; + const saved = await api( + `/api/v1/admin/videos/${video.video_hash}/sentences/batch`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + state.sentenceDraft = saved.map((sentence) => ({ ...sentence })); + state.sentencePending = false; + return true; + } catch (error) { + elements.serviceState.textContent = `句子批量处理失败: ${error.message}`; + return false; + } finally { + state.sentenceSaving = false; + if (state.sentenceDraft) renderSentenceDraft(video); + } +} + +function materializeSentenceDraftSplits(video) { + for (const sentence of [...(state.sentenceDraft || [])]) { + if (/\r?\n/.test(sentence.text || "")) { + splitDraftSentence(video, sentence, sentence.text, sentence.language); + } + } +} + +function resetSentenceDraft() { + state.sentenceDraft = null; + state.sentencePending = false; + state.sentenceSaving = false; + state.sentenceVideo = null; + state.sentenceBaseMeta = ""; +} + +function markSentenceDraftChanged(video) { + state.sentencePending = true; + renderSentenceDraft(video); +} + +function markSentenceDraftPending() { + state.sentencePending = true; + elements.sentenceMeta.textContent = [ + state.sentenceBaseMeta, + "有暂存修改", + ].filter(Boolean).join(" / "); + elements.repairGaps.disabled = true; + elements.repairMissingZh.disabled = true; +} + +function joinDraftSentenceText(left, right, language) { + const leftText = (left || "").trim(); + const rightText = (right || "").trim(); + if (!leftText) return rightText; + if (!rightText) return leftText; + const lang = (language || "").toLowerCase(); + if (lang.startsWith("zh") || lang.startsWith("ja") || lang.startsWith("ko")) { + return leftText + rightText; + } + const leftEnd = leftText.slice(-1); + const rightStart = rightText.slice(0, 1); + const cjk = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/; + if (cjk.test(leftEnd) || cjk.test(rightStart)) return leftText + rightText; + if (rightStart && ",.;:!?)]}、,。!?;:”’》〉】".includes(rightStart)) { + return leftText + rightText; + } + return `${leftText} ${rightText}`; +} + +function splitDraftSentence(video, sentence, text, language) { + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const duration = Math.max(1, sentence.end_ms - sentence.start_ms); + const weights = lines.map((line) => Math.max(1, line.replace(/\s+/g, "").length)); + const totalWeight = Math.max(1, weights.reduce((total, weight) => total + weight, 0)); + const sourceStart = sentence.sourceStartMs ?? sentence.originStartMs ?? sentence.start_ms; + const sourceEnd = sentence.sourceEndMs ?? sentence.originEndMs ?? sentence.end_ms; + const replacements = []; + let cursor = sentence.start_ms; + let remaining = lines.length; + let weightSum = 0; + for (const [lineIndex, line] of lines.entries()) { + const isLast = lineIndex === lines.length - 1; + weightSum += weights[lineIndex]; + let end = sentence.end_ms; + if (!isLast) { + end = sentence.start_ms + Math.round(duration * weightSum / totalWeight); + end = Math.min(Math.max(end, cursor + 1), sentence.end_ms - (remaining - 1)); + } + const sourceDuration = Math.max( + 1, + (sentence.reference_speech_duration_ms || duration) * weights[lineIndex] / totalWeight, + ); + replacements.push({ + index: sentence.index + lineIndex, + start_ms: cursor, + end_ms: end, + text: line, + language: language || sentence.language, + zh_text: null, + reference_speech_duration_ms: Math.max(1, Math.round(sourceDuration)), + sourceStartMs: sourceStart, + sourceEndMs: sourceEnd, + originStartMs: sourceStart, + originEndMs: sourceEnd, + }); + cursor = end; + remaining -= 1; + } + state.sentenceDraft.splice(sentence.index, 1, ...replacements); + renumberSentenceDraft(); + markSentenceDraftChanged(video); +} + +function renumberSentenceDraft() { + (state.sentenceDraft || []).forEach((sentence, index) => { + sentence.index = index; + }); +} + function collectSentenceGaps(sentences, durationMs, minGapMs = GAP_REPAIR_MIN_MS) { const gaps = []; let cursor = 0; @@ -371,6 +565,11 @@ function sentenceRow(video, sentence, totalCount) { if (translationNeedsRepair(sentence)) row.className = "missing-zh-row"; const textArea = document.createElement("textarea"); textArea.value = sentence.text || ""; + textArea.addEventListener("input", () => { + sentence.text = textArea.value; + sentence.zh_text = null; + markSentenceDraftPending(); + }); const language = document.createElement("select"); for (const [value, label] of [["", "自动"], ["en", "英语"], ["zh", "中文"]]) { const option = document.createElement("option"); @@ -379,6 +578,10 @@ function sentenceRow(video, sentence, totalCount) { option.selected = value === (sentence.language || ""); language.append(option); } + language.addEventListener("change", () => { + sentence.language = language.value || null; + markSentenceDraftPending(); + }); const textContainer = document.createElement("td"); const zhNote = document.createElement("div"); zhNote.className = "zh-text"; @@ -389,51 +592,39 @@ function sentenceRow(video, sentence, totalCount) { const languageContainer = document.createElement("td"); languageContainer.append(language); const saveContainer = document.createElement("td"); - const save = button("保存", async () => { - save.disabled = true; - try { - const body = JSON.stringify({ text: textArea.value, language: language.value || null }); - if (/\r?\n/.test(textArea.value)) { - await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/split`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body, - }); - save.textContent = "已拆分"; - await openSentences(video); - } else { - await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body, - }); - save.textContent = "已保存"; - } - } catch (error) { - elements.serviceState.textContent = `句子保存失败: ${error.message}`; - save.textContent = error.message; - } finally { - window.setTimeout(() => { save.disabled = false; save.textContent = "保存"; }, 1600); + const save = button("保存", () => { + if (/\r?\n/.test(sentence.text || "")) { + splitDraftSentence(video, sentence, sentence.text, language.value || null); + return; } + markSentenceDraftChanged(video); }); saveContainer.append(save); if (sentence.index < totalCount - 1) { - const merge = button("合并下句", async () => { + const merge = button("合并下句", () => { const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`; const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`); if (!confirmed) return; - merge.disabled = true; - try { - await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/merge`, { - method: "PUT", - }); - merge.textContent = "已合并"; - await openSentences(video); - } catch (error) { - elements.serviceState.textContent = `句子合并失败: ${error.message}`; - merge.textContent = error.message; - window.setTimeout(() => { merge.disabled = false; merge.textContent = "合并下句"; }, 1600); - } + const next = state.sentenceDraft[sentence.index + 1]; + const mergedLanguage = sentence.language || next.language; + const merged = { + index: sentence.index, + start_ms: sentence.start_ms, + end_ms: next.end_ms, + text: joinDraftSentenceText(sentence.text, next.text, mergedLanguage), + language: mergedLanguage, + zh_text: null, + reference_speech_duration_ms: Math.max( + 1, + (sentence.reference_speech_duration_ms || 1) + + (next.reference_speech_duration_ms || 1), + ), + sourceStartMs: undefined, + sourceEndMs: undefined, + }; + state.sentenceDraft.splice(sentence.index, 2, merged); + renumberSentenceDraft(); + markSentenceDraftChanged(video); }); saveContainer.append(merge); } @@ -460,35 +651,28 @@ function boundaryControl(video, sentence) { const status = document.createElement("span"); status.className = "boundary-status"; - async function adjust(deltaMs, changedButton) { - const buttons = [minus, plus]; - buttons.forEach((item) => { item.disabled = true; }); - status.textContent = ""; - try { - await api( - `/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/boundary`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ delta_ms: deltaMs }), - }, - ); - await openSentences(video); - } catch (error) { - elements.serviceState.textContent = `分割点调整失败: ${error.message}`; - status.textContent = "失败"; - buttons.forEach((item) => { item.disabled = false; }); - window.setTimeout(() => { status.textContent = ""; }, 1800); - } - if (changedButton) { - window.setTimeout(() => { - changedButton.textContent = deltaMs < 0 ? "−0.1" : "+0.1"; - }, 900); + function adjust(deltaMs) { + const next = state.sentenceDraft[sentence.index + 1]; + if (!next) return; + const nextBoundary = sentence.end_ms + deltaMs; + if ( + nextBoundary <= sentence.start_ms || + nextBoundary >= next.end_ms + ) { + elements.serviceState.textContent = "分割点必须保留在两句时间范围内"; + return; } + sentence.end_ms = nextBoundary; + next.start_ms = nextBoundary; + sentence.sourceStartMs = undefined; + sentence.sourceEndMs = undefined; + next.sourceStartMs = undefined; + next.sourceEndMs = undefined; + markSentenceDraftChanged(video); } - const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100, minus)); - const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100, plus)); + const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100)); + const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100)); minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒"; plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒"; container.append(minus, label, plus, status); diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index b039776..1e9fbf9 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -790,6 +790,7 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm VIDEO_HASH, {0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"}, ) + (settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"video") class UnavailableTranscriber: available = False @@ -833,6 +834,108 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm +def test_admin_batch_applies_merge_and_split_once(tmp_path, monkeypatch): + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Batch edit 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=12000, + algorithm_version="test", + sentences=[ + SentenceBoundary(index=0, start_ms=0, end_ms=2000, text="Before.", language="en", reference_speech_duration_ms=1800), + SentenceBoundary(index=1, start_ms=2000, end_ms=5000, text="First part", language="en", reference_speech_duration_ms=2800), + SentenceBoundary(index=2, start_ms=5000, end_ms=9000, text="second part", language="en", reference_speech_duration_ms=3600), + SentenceBoundary(index=3, start_ms=9000, end_ms=12000, text="Next sentence.", language="en", reference_speech_duration_ms=2800), + ], + ), + None, + ) + repository.save_sentence_translations( + VIDEO_HASH, + {0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"}, + ) + (settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"video") + + calls = [] + from sentence_api.transcription import Transcript, TranscriptionSegment, WordTimestamp + + class OnceTranscriber: + available = True + + def transcribe(self, audio_path, language=None): + calls.append(audio_path) + return Transcript( + text="Next sentence.", + segments=[ + TranscriptionSegment( + start_seconds=0.0, + end_seconds=1.0, + text="Next sentence.", + words=[ + WordTimestamp(0.0, 0.2, "Next"), + WordTimestamp(0.2, 1.0, "sentence."), + ], + ) + ], + ) + + monkeypatch.setattr( + "sentence_api.processing.extract_audio_segment", + lambda media_path, output_path, *, start_ms, duration_ms: output_path.write_bytes(b"audio"), + ) + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + transcriber=OnceTranscriber(), + ) + ) + response = client.put( + f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/batch", + json={ + "sentences": [ + {"start_ms": 0, "end_ms": 2000, "text": "Before.", "language": "en", "reference_speech_duration_ms": 1800}, + {"start_ms": 2000, "end_ms": 9000, "text": "First part second part", "language": "en", "reference_speech_duration_ms": 6400}, + {"start_ms": 9000, "end_ms": 9200, "text": "Next", "language": "en", "reference_speech_duration_ms": 1000, "source_start_ms": 9000, "source_end_ms": 12000}, + {"start_ms": 9200, "end_ms": 12000, "text": "sentence.", "language": "en", "reference_speech_duration_ms": 1000, "source_start_ms": 9000, "source_end_ms": 12000}, + ] + }, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert [item["text"] for item in payload] == [ + "Before.", + "First part second part", + "Next", + "sentence.", + ] + assert [item["start_ms"] for item in payload] == [0, 2000, 9000, 9600] + assert [item["end_ms"] for item in payload] == [2000, 9000, 9600, 12000] + assert [item["reference_speech_duration_ms"] for item in payload] == [1800, 6400, 862, 1938] + assert len(calls) == 1 + document = repository.get_document(VIDEO_HASH) + assert [item.text for item in document.sentences] == [ + "Before.", + "First part second part", + "Next", + "sentence.", + ] + assert all(not (item.zh_text or "").strip() for item in document.sentences) + + def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatch): client = make_client(tmp_path) _ready_video(client.app.state.video_repository)