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 3b3d8e5..3933297 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/sentence_api/DEPLOYMENT.md b/sentence_api/DEPLOYMENT.md index df5aae6..83e22ba 100644 --- a/sentence_api/DEPLOYMENT.md +++ b/sentence_api/DEPLOYMENT.md @@ -158,8 +158,9 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \ `sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积, 只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束; 逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有 -标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把 -该句拆成多句;也支持将相邻两句合并成一句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按 +标点处断开的 segment 会自动合并成同一句。管理后台会先暂存换行拆分、相邻句合并和 +结束时间微调;关闭句子对话框时才一次性提交,后台只启动一轮拆分时间戳/翻译处理。 +拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按 文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新, 并保证相邻句子不重叠。若转写服务支持词级时间戳 (`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用 diff --git a/sentence_api/README.md b/sentence_api/README.md index e4f4a4a..88de3ee 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -64,6 +64,7 @@ GET /api/v1/admin/videos/{sha256}/subtitle-translation POST /api/v1/admin/videos/{sha256}/subtitle-translation POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair PUT /api/v1/admin/videos/{sha256}/sentences/{index} +PUT /api/v1/admin/videos/{sha256}/sentences/batch PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary PUT /api/v1/admin/users/password diff --git a/sentence_api/main.py b/sentence_api/main.py index fa174eb..8d8a69c 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -50,6 +50,8 @@ from .models import ( SentenceExplanationRequest, SentenceExplanationResponse, AdminSubtitleTranslationResponse, + AdminSentenceBatchItem, + AdminSentenceBatchUpdate, LocalSubtitleTranslationRequest, SubtitleRetranslateRequest, SubtitleTranslationResponse, @@ -789,6 +791,30 @@ def create_app( raise HTTPException(status_code=404, detail="Video was not found.") return _video_summary(video, service_settings) + @application.put( + "/api/v1/admin/videos/{video_hash}/sentences/batch", + response_model=List[SentenceBoundary], + dependencies=[Depends(require_admin)], + ) + def batch_admin_sentences( + payload: AdminSentenceBatchUpdate, + video_hash: str = SHA256_PATH, + ) -> 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)