diff --git a/sentence_api/DEPLOYMENT.md b/sentence_api/DEPLOYMENT.md index 3dd2eae..25156b0 100644 --- a/sentence_api/DEPLOYMENT.md +++ b/sentence_api/DEPLOYMENT.md @@ -157,7 +157,8 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \ 逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有 标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把 该句拆成多句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按 -文本长度估算。若转写服务支持词级时间戳 +文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新, +并保证相邻句子不重叠。若转写服务支持词级时间戳 (`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用 标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为 不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。 diff --git a/sentence_api/README.md b/sentence_api/README.md index f103202..e33b7fa 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -57,6 +57,7 @@ PUT /api/v1/admin/videos/raw POST /api/v1/admin/videos/{sha256}/process PUT /api/v1/admin/videos/{sha256}/sentences/{index} PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split +PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary DELETE /api/v1/admin/videos/{sha256} ``` diff --git a/sentence_api/main.py b/sentence_api/main.py index fd70f02..c4bff5f 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -32,6 +32,7 @@ from .audio_metrics import AudioAnalysisError from .config import Settings from .models import ( AssessmentResult, + SentenceBoundaryAdjust, SentenceBoundary, SentenceBoundaryDocument, SentenceTextUpdate, @@ -363,6 +364,28 @@ def create_app( raise HTTPException(status_code=404, detail="Sentence was not found.") return result + @application.put( + "/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/boundary", + response_model=List[SentenceBoundary], + dependencies=[Depends(require_admin)], + ) + def adjust_sentence_boundary( + payload: SentenceBoundaryAdjust, + video_hash: str = SHA256_PATH, + sentence_index: int = ApiPath(ge=0), + ) -> List[SentenceBoundary]: + try: + result = video_repository.adjust_sentence_boundary( + video_hash, + sentence_index, + payload.delta_ms, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=404, detail="Sentence or video was not found.") + return result + @application.delete( "/api/v1/admin/videos/{video_hash}", status_code=204, diff --git a/sentence_api/models.py b/sentence_api/models.py index 0dd7bbc..e27a670 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -92,6 +92,12 @@ class SentenceTextUpdate(BaseModel): language: Optional[str] = Field(default=None, max_length=32) +class SentenceBoundaryAdjust(BaseModel): + model_config = ConfigDict(extra="forbid") + + delta_ms: int = Field(ge=-10_000, le=10_000) + + class TextSubstitution(BaseModel): expected: str actual: str diff --git a/sentence_api/repository.py b/sentence_api/repository.py index a9d3eb7..9fe7413 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -491,6 +491,121 @@ class VideoRepository: for row in result_rows ] + def adjust_sentence_boundary( + self, + video_hash: str, + sentence_index: int, + delta_ms: int, + ) -> Optional[List[SentenceBoundary]]: + """Move one sentence end and link the next sentence's start to it.""" + normalized_hash = normalize_video_hash(video_hash) + with self._connect() as connection: + video = connection.execute( + "SELECT duration_ms FROM videos WHERE video_hash = ?", + (normalized_hash,), + ).fetchone() + if video is None or video["duration_ms"] is None: + return None + + current = connection.execute( + """ + SELECT * FROM sentences + WHERE video_hash = ? AND sentence_index = ? + """, + (normalized_hash, sentence_index), + ).fetchone() + if current is None: + return None + + following = connection.execute( + """ + SELECT * FROM sentences + WHERE video_hash = ? AND sentence_index = ? + """, + (normalized_hash, sentence_index + 1), + ).fetchone() + + duration_ms = int(video["duration_ms"]) + minimum_end = current["start_ms"] + 1 + maximum_end = duration_ms + if following is not None: + maximum_end = min(maximum_end, following["end_ms"] - 1) + new_end_ms = current["end_ms"] + delta_ms + if new_end_ms < minimum_end or new_end_ms > maximum_end: + raise ValueError( + "The adjusted boundary must remain inside both sentences." + ) + + current_duration_ms = new_end_ms - current["start_ms"] + current_reference_ms = current["reference_speech_duration_ms"] or current_duration_ms + current_reference_ms = min( + current_duration_ms, + max(1, current_reference_ms + delta_ms), + ) + connection.execute( + """ + UPDATE sentences + SET end_ms = ?, reference_speech_duration_ms = ? + WHERE video_hash = ? AND sentence_index = ? + """, + ( + new_end_ms, + current_reference_ms, + normalized_hash, + sentence_index, + ), + ) + + updated_indexes = [sentence_index] + if following is not None: + old_start_ms = following["start_ms"] + next_duration_ms = following["end_ms"] - new_end_ms + next_reference_ms = following["reference_speech_duration_ms"] or next_duration_ms + next_reference_ms = min( + next_duration_ms, + max(1, next_reference_ms - (new_end_ms - old_start_ms)), + ) + connection.execute( + """ + UPDATE sentences + SET start_ms = ?, reference_speech_duration_ms = ? + WHERE video_hash = ? AND sentence_index = ? + """, + ( + new_end_ms, + next_reference_ms, + normalized_hash, + sentence_index + 1, + ), + ) + updated_indexes.append(sentence_index + 1) + + connection.execute( + "UPDATE videos SET updated_at = ? WHERE video_hash = ?", + (utc_now(), normalized_hash), + ) + placeholders = ", ".join("?" for _ in updated_indexes) + result_rows = connection.execute( + f""" + SELECT * FROM sentences + WHERE video_hash = ? AND sentence_index IN ({placeholders}) + ORDER BY sentence_index + """, + (normalized_hash, *updated_indexes), + ).fetchall() + + return [ + SentenceBoundary( + index=row["sentence_index"], + start_ms=row["start_ms"], + end_ms=row["end_ms"], + text=row["text"], + language=row["language"], + reference_speech_duration_ms=row["reference_speech_duration_ms"], + ) + for row in result_rows + ] + def create_dub_share( self, *, diff --git a/sentence_api/static/admin.css b/sentence_api/static/admin.css index 648e737..a0cdb22 100644 --- a/sentence_api/static/admin.css +++ b/sentence_api/static/admin.css @@ -106,6 +106,10 @@ dialog::backdrop { background: rgb(20 25 28 / 55%); } .sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; } .sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; } .sentence-scroll select { min-width: 90px; } +.boundary-control { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; white-space: nowrap; } +.boundary-control button { min-height: 24px; padding: 2px 6px; font-size: 11px; line-height: 1; } +.boundary-time { display: inline-block; min-width: 56px; text-align: center; font-variant-numeric: tabular-nums; } +.boundary-status { min-width: 26px; color: #a12622; font-size: 11px; } @media (max-width: 820px) { .topbar { align-items: stretch; flex-direction: column; padding: 14px 16px; } diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 457ffc2..dc2ccf1 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -91,6 +91,6 @@ - + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index f78be87..413260a 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -239,9 +239,13 @@ function sentenceRow(video, sentence) { } }); saveContainer.append(save); + const timeContainer = document.createElement("td"); + const startLabel = document.createElement("span"); + startLabel.textContent = formatDuration(sentence.start_ms); + timeContainer.append(startLabel, " — ", boundaryControl(video, sentence)); row.append( textCell(String(sentence.index + 1)), - textCell(`${formatDuration(sentence.start_ms)} - ${formatDuration(sentence.end_ms)}`), + timeContainer, textContainer, languageContainer, saveContainer, @@ -249,6 +253,50 @@ function sentenceRow(video, sentence) { return row; } +function boundaryControl(video, sentence) { + const container = document.createElement("div"); + container.className = "boundary-control"; + const label = document.createElement("span"); + label.className = "boundary-time"; + label.textContent = formatDuration(sentence.end_ms); + 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); + } + } + + const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100, minus)); + const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100, plus)); + minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒"; + plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒"; + container.append(minus, label, plus, status); + return container; +} + async function reprocess(videoHash) { try { await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" }); diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index e438cea..8edc519 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -259,3 +259,72 @@ def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path): "Next sentence.", ] assert [item.index for item in document.sentences] == [0, 1, 2] + + +def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path): + 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="Boundary 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=9000, + algorithm_version="test", + sentences=[ + SentenceBoundary( + index=0, + start_ms=0, + end_ms=4000, + text="First.", + language="en", + reference_speech_duration_ms=3600, + ), + SentenceBoundary( + index=1, + start_ms=4000, + end_ms=9000, + text="Second.", + language="en", + reference_speech_duration_ms=4600, + ), + ], + ), + None, + ) + + class UnavailableTranscriber: + available = False + + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + transcriber=UnavailableTranscriber(), + ) + ) + response = client.put( + f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/boundary", + json={"delta_ms": 250}, + ) + + assert response.status_code == 200 + updated = response.json() + assert [item["index"] for item in updated] == [0, 1] + assert updated[0]["end_ms"] == 4250 + assert updated[1]["start_ms"] == 4250 + assert updated[0]["reference_speech_duration_ms"] == 3850 + assert updated[1]["reference_speech_duration_ms"] == 4350 + + document = repository.get_document(VIDEO_HASH) + assert [item.end_ms for item in document.sentences] == [4250, 9000] + assert [item.start_ms for item in document.sentences] == [0, 4250]