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