later process

This commit is contained in:
2026-10-29 08:48:49 +08:00
parent 03e7987c7c
commit 031f3c6f21
9 changed files with 541 additions and 85 deletions

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.5" versionName = "0.1.6"
} }
compileOptions { compileOptions {

View File

@@ -158,8 +158,9 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
`sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积, `sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积,
只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束; 只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束;
逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有 逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有
标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把 标点处断开的 segment 会自动合并成同一句。管理后台会先暂存换行拆分、相邻句合并和
该句拆成多句;也支持将相邻两句合并成一句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按 结束时间微调;关闭句子对话框时才一次性提交,后台只启动一轮拆分时间戳/翻译处理。
拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按
文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新, 文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新,
并保证相邻句子不重叠。若转写服务支持词级时间戳 并保证相邻句子不重叠。若转写服务支持词级时间戳
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用 (`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用

View File

@@ -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
POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair
PUT /api/v1/admin/videos/{sha256}/sentences/{index} 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}/split
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
PUT /api/v1/admin/users/password PUT /api/v1/admin/users/password

View File

@@ -50,6 +50,8 @@ from .models import (
SentenceExplanationRequest, SentenceExplanationRequest,
SentenceExplanationResponse, SentenceExplanationResponse,
AdminSubtitleTranslationResponse, AdminSubtitleTranslationResponse,
AdminSentenceBatchItem,
AdminSentenceBatchUpdate,
LocalSubtitleTranslationRequest, LocalSubtitleTranslationRequest,
SubtitleRetranslateRequest, SubtitleRetranslateRequest,
SubtitleTranslationResponse, SubtitleTranslationResponse,
@@ -789,6 +791,30 @@ def create_app(
raise HTTPException(status_code=404, detail="Video was not found.") raise HTTPException(status_code=404, detail="Video was not found.")
return _video_summary(video, service_settings) 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( @application.put(
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}", "/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}",
response_model=SentenceBoundary, 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) 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: def _video_summary(row: Dict[str, Any], settings: Settings) -> VideoSummary:
if row.get("remote_url"): if row.get("remote_url"):
stream_url = row["remote_url"] stream_url = row["remote_url"]

View File

@@ -93,6 +93,34 @@ class SentenceTextUpdate(BaseModel):
language: Optional[str] = Field(default=None, max_length=32) 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): class CourseTitleUpdate(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -126,7 +126,7 @@
<div class="dialog-heading-actions"> <div class="dialog-heading-actions">
<button id="repair-gaps" type="button">修复空白</button> <button id="repair-gaps" type="button">修复空白</button>
<button id="repair-missing-zh" type="button">补译缺失</button> <button id="repair-missing-zh" type="button">补译缺失</button>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button> <button id="close-dialog" class="icon-button" type="button" aria-label="关闭" title="关闭时批量处理暂存修改">X</button>
</div> </div>
</div> </div>
<div class="sentence-scroll"> <div class="sentence-scroll">
@@ -139,6 +139,6 @@
</div> </div>
</dialog> </dialog>
<script src="/static/admin.js?v=admin-repair-zh-v1" defer></script> <script src="/static/admin.js?v=admin-sentence-batch-v1" defer></script>
</body> </body>
</html> </html>

View File

@@ -7,6 +7,10 @@ const state = {
users: [], users: [],
retranslateVideo: null, retranslateVideo: null,
sentenceVideo: null, sentenceVideo: null,
sentenceDraft: null,
sentenceBaseMeta: "",
sentencePending: false,
sentenceSaving: false,
}; };
const elements = { const elements = {
@@ -52,10 +56,19 @@ elements.refresh.addEventListener("click", () => {
loadUsers(); loadUsers();
}); });
elements.closeDialog.addEventListener("click", () => elements.dialog.close()); elements.closeDialog.addEventListener("click", () => elements.dialog.close());
elements.dialog.addEventListener("close", handleSentenceDialogClose);
elements.repairGaps.addEventListener("click", () => { elements.repairGaps.addEventListener("click", () => {
if (state.sentencePending) {
elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理";
return;
}
if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true }); if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true });
}); });
elements.repairMissingZh.addEventListener("click", () => { elements.repairMissingZh.addEventListener("click", () => {
if (state.sentencePending) {
elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理";
return;
}
if (state.sentenceVideo) repairMissingTranslations(state.sentenceVideo, { reloadSentences: true }); if (state.sentenceVideo) repairMissingTranslations(state.sentenceVideo, { reloadSentences: true });
}); });
elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close()); elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
@@ -297,19 +310,59 @@ function readXhrError(request) {
} }
async function openSentences(video) { 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 { try {
const detail = await api(`/api/v1/videos/${video.video_hash}`); const detail = await api(`/api/v1/videos/${video.video_hash}`);
state.sentenceVideo = video; state.sentenceVideo = video;
elements.sentenceTitle.textContent = detail.video.title; 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 durationMs = detail.video.duration_ms || 0;
const sentences = state.sentenceDraft;
const gaps = collectSentenceGaps(sentences, durationMs); const gaps = collectSentenceGaps(sentences, durationMs);
const missingZh = sentences.filter(translationNeedsRepair).length; const missingZh = sentences.filter(translationNeedsRepair).length;
const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : ""; const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : "";
const zhLabel = missingZh ? ` / ${missingZh} 句未译` : ""; const zhLabel = missingZh ? ` / ${missingZh} 句未译` : "";
elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`; state.sentenceBaseMeta = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`;
elements.repairGaps.disabled = gaps.length === 0; renderSentenceDraft(video);
elements.repairMissingZh.disabled = missingZh === 0; 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(); elements.sentenceRows.replaceChildren();
let gapIndex = 0; let gapIndex = 0;
for (const sentence of sentences) { for (const sentence of sentences) {
@@ -322,10 +375,151 @@ async function openSentences(video) {
if (gapIndex < gaps.length) { if (gapIndex < gaps.length) {
elements.sentenceRows.append(gapRow(gaps[gapIndex])); elements.sentenceRows.append(gapRow(gaps[gapIndex]));
} }
if (!elements.dialog.open) elements.dialog.showModal(); }
} catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`; 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) { function collectSentenceGaps(sentences, durationMs, minGapMs = GAP_REPAIR_MIN_MS) {
@@ -371,6 +565,11 @@ function sentenceRow(video, sentence, totalCount) {
if (translationNeedsRepair(sentence)) row.className = "missing-zh-row"; if (translationNeedsRepair(sentence)) row.className = "missing-zh-row";
const textArea = document.createElement("textarea"); const textArea = document.createElement("textarea");
textArea.value = sentence.text || ""; textArea.value = sentence.text || "";
textArea.addEventListener("input", () => {
sentence.text = textArea.value;
sentence.zh_text = null;
markSentenceDraftPending();
});
const language = document.createElement("select"); const language = document.createElement("select");
for (const [value, label] of [["", "自动"], ["en", "英语"], ["zh", "中文"]]) { for (const [value, label] of [["", "自动"], ["en", "英语"], ["zh", "中文"]]) {
const option = document.createElement("option"); const option = document.createElement("option");
@@ -379,6 +578,10 @@ function sentenceRow(video, sentence, totalCount) {
option.selected = value === (sentence.language || ""); option.selected = value === (sentence.language || "");
language.append(option); language.append(option);
} }
language.addEventListener("change", () => {
sentence.language = language.value || null;
markSentenceDraftPending();
});
const textContainer = document.createElement("td"); const textContainer = document.createElement("td");
const zhNote = document.createElement("div"); const zhNote = document.createElement("div");
zhNote.className = "zh-text"; zhNote.className = "zh-text";
@@ -389,51 +592,39 @@ function sentenceRow(video, sentence, totalCount) {
const languageContainer = document.createElement("td"); const languageContainer = document.createElement("td");
languageContainer.append(language); languageContainer.append(language);
const saveContainer = document.createElement("td"); const saveContainer = document.createElement("td");
const save = button("保存", async () => { const save = button("保存", () => {
save.disabled = true; if (/\r?\n/.test(sentence.text || "")) {
try { splitDraftSentence(video, sentence, sentence.text, language.value || null);
const body = JSON.stringify({ text: textArea.value, language: language.value || null }); return;
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);
} }
markSentenceDraftChanged(video);
}); });
saveContainer.append(save); saveContainer.append(save);
if (sentence.index < totalCount - 1) { if (sentence.index < totalCount - 1) {
const merge = button("合并下句", async () => { const merge = button("合并下句", () => {
const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`; const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`;
const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`); const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`);
if (!confirmed) return; if (!confirmed) return;
merge.disabled = true; const next = state.sentenceDraft[sentence.index + 1];
try { const mergedLanguage = sentence.language || next.language;
await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/merge`, { const merged = {
method: "PUT", index: sentence.index,
}); start_ms: sentence.start_ms,
merge.textContent = "已合并"; end_ms: next.end_ms,
await openSentences(video); text: joinDraftSentenceText(sentence.text, next.text, mergedLanguage),
} catch (error) { language: mergedLanguage,
elements.serviceState.textContent = `句子合并失败: ${error.message}`; zh_text: null,
merge.textContent = error.message; reference_speech_duration_ms: Math.max(
window.setTimeout(() => { merge.disabled = false; merge.textContent = "合并下句"; }, 1600); 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); saveContainer.append(merge);
} }
@@ -460,35 +651,28 @@ function boundaryControl(video, sentence) {
const status = document.createElement("span"); const status = document.createElement("span");
status.className = "boundary-status"; status.className = "boundary-status";
async function adjust(deltaMs, changedButton) { function adjust(deltaMs) {
const buttons = [minus, plus]; const next = state.sentenceDraft[sentence.index + 1];
buttons.forEach((item) => { item.disabled = true; }); if (!next) return;
status.textContent = ""; const nextBoundary = sentence.end_ms + deltaMs;
try { if (
await api( nextBoundary <= sentence.start_ms ||
`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/boundary`, nextBoundary >= next.end_ms
{ ) {
method: "PUT", elements.serviceState.textContent = "分割点必须保留在两句时间范围内";
headers: { "Content-Type": "application/json" }, return;
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);
} }
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 minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100));
const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100, plus)); const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100));
minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒"; minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒";
plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒"; plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒";
container.append(minus, label, plus, status); container.append(minus, label, plus, status);

View File

@@ -790,6 +790,7 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm
VIDEO_HASH, VIDEO_HASH,
{0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"}, {0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"},
) )
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"video")
class UnavailableTranscriber: class UnavailableTranscriber:
available = False 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): def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatch):
client = make_client(tmp_path) client = make_client(tmp_path)
_ready_video(client.app.state.video_repository) _ready_video(client.app.state.video_repository)