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