fixed bugs
This commit is contained in:
@@ -158,9 +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 支持),句子的结束时间用
|
||||||
|
|||||||
@@ -812,7 +812,10 @@ def create_app(
|
|||||||
raise HTTPException(status_code=404, detail="Course is not available.") from None
|
raise HTTPException(status_code=404, detail="Course is not available.") from None
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
application.state.subtitle_translations.refresh(video_hash)
|
if payload.process_timings:
|
||||||
|
application.state.subtitle_translations.refresh(video_hash)
|
||||||
|
else:
|
||||||
|
application.state.subtitle_translations.invalidate(video_hash)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@application.put(
|
@application.put(
|
||||||
@@ -833,7 +836,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
if sentence is None:
|
if sentence is None:
|
||||||
raise HTTPException(status_code=404, detail="Sentence was not found.")
|
raise HTTPException(status_code=404, detail="Sentence was not found.")
|
||||||
application.state.subtitle_translations.refresh(video_hash)
|
application.state.subtitle_translations.invalidate(video_hash)
|
||||||
return sentence
|
return sentence
|
||||||
|
|
||||||
@application.put(
|
@application.put(
|
||||||
@@ -891,7 +894,7 @@ def create_app(
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
if sentence is None:
|
if sentence is None:
|
||||||
raise HTTPException(status_code=404, detail="Sentence was not found.")
|
raise HTTPException(status_code=404, detail="Sentence was not found.")
|
||||||
application.state.subtitle_translations.refresh(video_hash)
|
application.state.subtitle_translations.invalidate(video_hash)
|
||||||
return sentence
|
return sentence
|
||||||
|
|
||||||
@application.put(
|
@application.put(
|
||||||
@@ -1261,6 +1264,7 @@ def _replace_admin_sentences(
|
|||||||
end_ms=item.end_ms,
|
end_ms=item.end_ms,
|
||||||
text=item.text.strip(),
|
text=item.text.strip(),
|
||||||
language=item.language,
|
language=item.language,
|
||||||
|
zh_text=item.zh_text,
|
||||||
reference_speech_duration_ms=item.reference_speech_duration_ms,
|
reference_speech_duration_ms=item.reference_speech_duration_ms,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1279,7 +1283,32 @@ def _replace_admin_sentences(
|
|||||||
group = payload.sentences[position:group_end]
|
group = payload.sentences[position:group_end]
|
||||||
source = old_sentences.get(source_key)
|
source = old_sentences.get(source_key)
|
||||||
if source is None:
|
if source is None:
|
||||||
raise ValueError("The original sentence for a queued split no longer exists.")
|
if payload.process_timings:
|
||||||
|
# A split may already have been persisted with provisional
|
||||||
|
# boundaries. Reconstruct its audio range from the saved rows so
|
||||||
|
# timing refinement can be retried after a dialog is reopened.
|
||||||
|
missing = [
|
||||||
|
item
|
||||||
|
for item in group
|
||||||
|
if (item.start_ms, item.end_ms) not in old_sentences
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError("The original sentence for a queued split no longer exists.")
|
||||||
|
source = SentenceBoundary(
|
||||||
|
index=0,
|
||||||
|
start_ms=source_key[0],
|
||||||
|
end_ms=source_key[1],
|
||||||
|
text="\n".join(item.text.strip() for item in group),
|
||||||
|
language=next(
|
||||||
|
(item.language for item in group if item.language is not None),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
reference_speech_duration_ms=sum(
|
||||||
|
item.reference_speech_duration_ms for item in group
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
source = None
|
||||||
if len(group) == 1:
|
if len(group) == 1:
|
||||||
if "\n" in group[0].text or "\r" in group[0].text:
|
if "\n" in group[0].text or "\r" in group[0].text:
|
||||||
raise ValueError("Unsplit batch sentences cannot contain line breaks.")
|
raise ValueError("Unsplit batch sentences cannot contain line breaks.")
|
||||||
@@ -1290,9 +1319,24 @@ def _replace_admin_sentences(
|
|||||||
end_ms=group[0].end_ms,
|
end_ms=group[0].end_ms,
|
||||||
text=group[0].text.strip(),
|
text=group[0].text.strip(),
|
||||||
language=group[0].language,
|
language=group[0].language,
|
||||||
|
zh_text=group[0].zh_text,
|
||||||
reference_speech_duration_ms=group[0].reference_speech_duration_ms,
|
reference_speech_duration_ms=group[0].reference_speech_duration_ms,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif not payload.process_timings:
|
||||||
|
for item in group:
|
||||||
|
rows.append(
|
||||||
|
SentenceBoundary(
|
||||||
|
index=index,
|
||||||
|
start_ms=item.start_ms,
|
||||||
|
end_ms=item.end_ms,
|
||||||
|
text=item.text.strip(),
|
||||||
|
language=item.language,
|
||||||
|
zh_text=item.zh_text,
|
||||||
|
reference_speech_duration_ms=item.reference_speech_duration_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
index += 1
|
||||||
else:
|
else:
|
||||||
split_text = "\n".join(item.text.strip() for item in group if item.text.strip())
|
split_text = "\n".join(item.text.strip() for item in group if item.text.strip())
|
||||||
replacements = split_sentence_text(
|
replacements = split_sentence_text(
|
||||||
@@ -1319,6 +1363,7 @@ def _replace_admin_sentences(
|
|||||||
if item.language is not None
|
if item.language is not None
|
||||||
else source.language
|
else source.language
|
||||||
),
|
),
|
||||||
|
zh_text=None,
|
||||||
reference_speech_duration_ms=replacement.reference_speech_duration_ms,
|
reference_speech_duration_ms=replacement.reference_speech_duration_ms,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class AdminSentenceBatchItem(BaseModel):
|
|||||||
end_ms: int = Field(gt=0)
|
end_ms: int = Field(gt=0)
|
||||||
text: str = Field(min_length=1, max_length=4000)
|
text: str = Field(min_length=1, max_length=4000)
|
||||||
language: Optional[str] = Field(default=None, max_length=32)
|
language: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
zh_text: Optional[str] = Field(default=None, max_length=4000)
|
||||||
reference_speech_duration_ms: int = Field(gt=0)
|
reference_speech_duration_ms: int = Field(gt=0)
|
||||||
source_start_ms: Optional[int] = Field(default=None, ge=0)
|
source_start_ms: Optional[int] = Field(default=None, ge=0)
|
||||||
source_end_ms: Optional[int] = Field(default=None, gt=0)
|
source_end_ms: Optional[int] = Field(default=None, gt=0)
|
||||||
@@ -119,6 +120,7 @@ class AdminSentenceBatchUpdate(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
sentences: List[AdminSentenceBatchItem] = Field(min_length=1, max_length=10000)
|
sentences: List[AdminSentenceBatchItem] = Field(min_length=1, max_length=10000)
|
||||||
|
process_timings: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
class CourseTitleUpdate(BaseModel):
|
class CourseTitleUpdate(BaseModel):
|
||||||
|
|||||||
@@ -602,17 +602,14 @@ class VideoRepository:
|
|||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE sentences SET text = ?, language = COALESCE(?, language)
|
UPDATE sentences SET text = ?, language = COALESCE(?, language),
|
||||||
|
zh_text = NULL
|
||||||
WHERE video_hash = ? AND sentence_index = ?
|
WHERE video_hash = ? AND sentence_index = ?
|
||||||
""",
|
""",
|
||||||
(text.strip(), language, normalized_hash, sentence_index),
|
(text.strip(), language, normalized_hash, sentence_index),
|
||||||
)
|
)
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
connection.execute(
|
|
||||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
|
||||||
(normalized_hash,),
|
|
||||||
)
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||||
(utc_now(), normalized_hash),
|
(utc_now(), normalized_hash),
|
||||||
@@ -817,7 +814,7 @@ class VideoRepository:
|
|||||||
"""
|
"""
|
||||||
UPDATE sentences
|
UPDATE sentences
|
||||||
SET end_ms = ?, text = ?, language = ?,
|
SET end_ms = ?, text = ?, language = ?,
|
||||||
reference_speech_duration_ms = ?
|
reference_speech_duration_ms = ?, zh_text = NULL
|
||||||
WHERE video_hash = ? AND sentence_index = ?
|
WHERE video_hash = ? AND sentence_index = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -858,10 +855,6 @@ class VideoRepository:
|
|||||||
(old_index - 1, normalized_hash, old_index),
|
(old_index - 1, normalized_hash, old_index),
|
||||||
)
|
)
|
||||||
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
|
||||||
(normalized_hash,),
|
|
||||||
)
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||||
(utc_now(), normalized_hash),
|
(utc_now(), normalized_hash),
|
||||||
|
|||||||
@@ -139,6 +139,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=admin-sentence-batch-v1" defer></script>
|
<script src="/static/admin.js?v=admin-sentence-batch-v3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const state = {
|
|||||||
sentenceBaseMeta: "",
|
sentenceBaseMeta: "",
|
||||||
sentencePending: false,
|
sentencePending: false,
|
||||||
sentenceSaving: false,
|
sentenceSaving: false,
|
||||||
|
sentenceNeedsProcessing: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
@@ -335,6 +336,7 @@ async function openSentences(video) {
|
|||||||
}));
|
}));
|
||||||
state.sentencePending = false;
|
state.sentencePending = false;
|
||||||
state.sentenceSaving = false;
|
state.sentenceSaving = false;
|
||||||
|
state.sentenceNeedsProcessing = false;
|
||||||
const durationMs = detail.video.duration_ms || 0;
|
const durationMs = detail.video.duration_ms || 0;
|
||||||
const sentences = state.sentenceDraft;
|
const sentences = state.sentenceDraft;
|
||||||
const gaps = collectSentenceGaps(sentences, durationMs);
|
const gaps = collectSentenceGaps(sentences, durationMs);
|
||||||
@@ -357,6 +359,7 @@ function renderSentenceDraft(video) {
|
|||||||
elements.sentenceMeta.textContent = [
|
elements.sentenceMeta.textContent = [
|
||||||
state.sentenceBaseMeta,
|
state.sentenceBaseMeta,
|
||||||
state.sentencePending ? "有暂存修改" : "",
|
state.sentencePending ? "有暂存修改" : "",
|
||||||
|
state.sentenceNeedsProcessing ? "已入库待处理" : "",
|
||||||
state.sentenceSaving ? "正在处理" : "",
|
state.sentenceSaving ? "正在处理" : "",
|
||||||
].filter(Boolean).join(" / ");
|
].filter(Boolean).join(" / ");
|
||||||
elements.repairGaps.disabled = gaps.length === 0 || state.sentencePending;
|
elements.repairGaps.disabled = gaps.length === 0 || state.sentencePending;
|
||||||
@@ -379,12 +382,19 @@ function renderSentenceDraft(video) {
|
|||||||
|
|
||||||
async function handleSentenceDialogClose() {
|
async function handleSentenceDialogClose() {
|
||||||
if (!state.sentenceDraft || state.sentenceSaving) return;
|
if (!state.sentenceDraft || state.sentenceSaving) return;
|
||||||
if (!state.sentencePending) {
|
if (!state.sentencePending && !state.sentenceNeedsProcessing) {
|
||||||
resetSentenceDraft();
|
resetSentenceDraft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const saved = await flushSentenceDraft();
|
if (state.sentencePending) {
|
||||||
if (!saved) {
|
const persisted = await saveSentenceDraft({ processTimings: false });
|
||||||
|
if (!persisted) {
|
||||||
|
if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const processed = await saveSentenceDraft({ processTimings: true });
|
||||||
|
if (!processed) {
|
||||||
if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal();
|
if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -392,15 +402,23 @@ async function handleSentenceDialogClose() {
|
|||||||
loadVideos();
|
loadVideos();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushSentenceDraft() {
|
async function saveSentenceDraft({ processTimings = false } = {}) {
|
||||||
const video = state.sentenceVideo;
|
const video = state.sentenceVideo;
|
||||||
if (!video || !state.sentencePending) return true;
|
if (!video) return false;
|
||||||
|
if (!processTimings && !state.sentencePending) return true;
|
||||||
|
if (processTimings && !state.sentencePending && !state.sentenceNeedsProcessing) return true;
|
||||||
materializeSentenceDraftSplits(video);
|
materializeSentenceDraftSplits(video);
|
||||||
state.sentenceSaving = true;
|
state.sentenceSaving = true;
|
||||||
renderSentenceDraft(video);
|
renderSentenceDraft(video);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
sentences: state.sentenceDraft.map(({ index, zh_text, ...sentence }) => sentence),
|
process_timings: processTimings,
|
||||||
|
sentences: state.sentenceDraft.map(({
|
||||||
|
index,
|
||||||
|
originStartMs,
|
||||||
|
originEndMs,
|
||||||
|
...sentence
|
||||||
|
}) => sentence),
|
||||||
};
|
};
|
||||||
const saved = await api(
|
const saved = await api(
|
||||||
`/api/v1/admin/videos/${video.video_hash}/sentences/batch`,
|
`/api/v1/admin/videos/${video.video_hash}/sentences/batch`,
|
||||||
@@ -410,11 +428,26 @@ async function flushSentenceDraft() {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
state.sentenceDraft = saved.map((sentence) => ({ ...sentence }));
|
const previousByRange = new Map(
|
||||||
|
state.sentenceDraft.map((sentence) => [`${sentence.start_ms}:${sentence.end_ms}`, sentence]),
|
||||||
|
);
|
||||||
|
state.sentenceDraft = saved.map((sentence) => {
|
||||||
|
const previous = previousByRange.get(`${sentence.start_ms}:${sentence.end_ms}`);
|
||||||
|
return {
|
||||||
|
...sentence,
|
||||||
|
originStartMs: previous?.originStartMs ?? sentence.start_ms,
|
||||||
|
originEndMs: previous?.originEndMs ?? sentence.end_ms,
|
||||||
|
sourceStartMs: previous?.sourceStartMs,
|
||||||
|
sourceEndMs: previous?.sourceEndMs,
|
||||||
|
};
|
||||||
|
});
|
||||||
state.sentencePending = false;
|
state.sentencePending = false;
|
||||||
|
state.sentenceNeedsProcessing = !processTimings;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elements.serviceState.textContent = `句子批量处理失败: ${error.message}`;
|
elements.serviceState.textContent = processTimings
|
||||||
|
? `句子批量处理失败: ${error.message}`
|
||||||
|
: `句子入库失败: ${error.message}`;
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.sentenceSaving = false;
|
state.sentenceSaving = false;
|
||||||
@@ -434,6 +467,7 @@ function resetSentenceDraft() {
|
|||||||
state.sentenceDraft = null;
|
state.sentenceDraft = null;
|
||||||
state.sentencePending = false;
|
state.sentencePending = false;
|
||||||
state.sentenceSaving = false;
|
state.sentenceSaving = false;
|
||||||
|
state.sentenceNeedsProcessing = false;
|
||||||
state.sentenceVideo = null;
|
state.sentenceVideo = null;
|
||||||
state.sentenceBaseMeta = "";
|
state.sentenceBaseMeta = "";
|
||||||
}
|
}
|
||||||
@@ -592,39 +626,64 @@ 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("保存", () => {
|
const save = button("保存", async () => {
|
||||||
if (/\r?\n/.test(sentence.text || "")) {
|
save.disabled = true;
|
||||||
splitDraftSentence(video, sentence, sentence.text, language.value || null);
|
save.textContent = "保存中";
|
||||||
return;
|
try {
|
||||||
|
if (/\r?\n/.test(sentence.text || "")) {
|
||||||
|
splitDraftSentence(video, sentence, sentence.text, language.value || null);
|
||||||
|
} else {
|
||||||
|
sentence.text = textArea.value;
|
||||||
|
sentence.language = language.value || null;
|
||||||
|
sentence.zh_text = null;
|
||||||
|
markSentenceDraftChanged(video);
|
||||||
|
}
|
||||||
|
await saveSentenceDraft({ processTimings: false });
|
||||||
|
} finally {
|
||||||
|
save.disabled = false;
|
||||||
|
save.textContent = "保存";
|
||||||
}
|
}
|
||||||
markSentenceDraftChanged(video);
|
|
||||||
});
|
});
|
||||||
saveContainer.append(save);
|
saveContainer.append(save);
|
||||||
if (sentence.index < totalCount - 1) {
|
if (sentence.index < totalCount - 1) {
|
||||||
const merge = button("合并下句", () => {
|
const merge = button("合并下句", async () => {
|
||||||
const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`;
|
const preview = `${(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;
|
||||||
const next = state.sentenceDraft[sentence.index + 1];
|
const sourceStart = sentence.start_ms;
|
||||||
const mergedLanguage = sentence.language || next.language;
|
const sourceEnd = sentence.end_ms;
|
||||||
const merged = {
|
merge.disabled = true;
|
||||||
index: sentence.index,
|
merge.textContent = "入库中";
|
||||||
start_ms: sentence.start_ms,
|
try {
|
||||||
end_ms: next.end_ms,
|
if (state.sentencePending) {
|
||||||
text: joinDraftSentenceText(sentence.text, next.text, mergedLanguage),
|
const persisted = await saveSentenceDraft({ processTimings: false });
|
||||||
language: mergedLanguage,
|
if (!persisted) return;
|
||||||
zh_text: null,
|
}
|
||||||
reference_speech_duration_ms: Math.max(
|
const targetIndex = state.sentenceDraft.findIndex(
|
||||||
1,
|
(item) => item.start_ms === sourceStart && item.end_ms === sourceEnd,
|
||||||
(sentence.reference_speech_duration_ms || 1) +
|
);
|
||||||
(next.reference_speech_duration_ms || 1),
|
if (targetIndex < 0) throw new Error("找不到要合并的句子");
|
||||||
),
|
const merged = await api(
|
||||||
sourceStartMs: undefined,
|
`/api/v1/admin/videos/${video.video_hash}/sentences/${targetIndex}/merge`,
|
||||||
sourceEndMs: undefined,
|
{ method: "PUT" },
|
||||||
};
|
);
|
||||||
state.sentenceDraft.splice(sentence.index, 2, merged);
|
state.sentenceDraft.splice(targetIndex, 2, {
|
||||||
renumberSentenceDraft();
|
...merged,
|
||||||
markSentenceDraftChanged(video);
|
originStartMs: merged.start_ms,
|
||||||
|
originEndMs: merged.end_ms,
|
||||||
|
sourceStartMs: undefined,
|
||||||
|
sourceEndMs: undefined,
|
||||||
|
});
|
||||||
|
renumberSentenceDraft();
|
||||||
|
state.sentencePending = false;
|
||||||
|
state.sentenceNeedsProcessing = true;
|
||||||
|
} catch (error) {
|
||||||
|
elements.serviceState.textContent = `句子合并失败: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
merge.disabled = false;
|
||||||
|
merge.textContent = "合并下句";
|
||||||
|
renderSentenceDraft(video);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
saveContainer.append(merge);
|
saveContainer.append(merge);
|
||||||
}
|
}
|
||||||
@@ -651,7 +710,7 @@ function boundaryControl(video, sentence) {
|
|||||||
const status = document.createElement("span");
|
const status = document.createElement("span");
|
||||||
status.className = "boundary-status";
|
status.className = "boundary-status";
|
||||||
|
|
||||||
function adjust(deltaMs) {
|
async function adjust(deltaMs) {
|
||||||
const next = state.sentenceDraft[sentence.index + 1];
|
const next = state.sentenceDraft[sentence.index + 1];
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
const nextBoundary = sentence.end_ms + deltaMs;
|
const nextBoundary = sentence.end_ms + deltaMs;
|
||||||
@@ -669,6 +728,7 @@ function boundaryControl(video, sentence) {
|
|||||||
next.sourceStartMs = undefined;
|
next.sourceStartMs = undefined;
|
||||||
next.sourceEndMs = undefined;
|
next.sourceEndMs = undefined;
|
||||||
markSentenceDraftChanged(video);
|
markSentenceDraftChanged(video);
|
||||||
|
await saveSentenceDraft({ processTimings: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100));
|
const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100));
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm
|
|||||||
assert [item.index for item in document.sentences] == [0, 1, 2]
|
assert [item.index for item in document.sentences] == [0, 1, 2]
|
||||||
assert [item.start_ms for item in document.sentences] == [0, 2000, 9000]
|
assert [item.start_ms for item in document.sentences] == [0, 2000, 9000]
|
||||||
assert [item.end_ms for item in document.sentences] == [2000, 9000, 12000]
|
assert [item.end_ms for item in document.sentences] == [2000, 9000, 12000]
|
||||||
assert all(not (item.zh_text or "").strip() for item in document.sentences)
|
assert [item.zh_text for item in document.sentences] == ["之前。", None, "下一句。"]
|
||||||
|
|
||||||
last_response = client.put(
|
last_response = client.put(
|
||||||
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge",
|
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge",
|
||||||
@@ -936,7 +936,73 @@ def test_admin_batch_applies_merge_and_split_once(tmp_path, monkeypatch):
|
|||||||
assert all(not (item.zh_text or "").strip() for item in document.sentences)
|
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_batch_can_persist_provisional_split_without_processing(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="Provisional split 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=5000, text="First part second part", language="en", reference_speech_duration_ms=4600),
|
||||||
|
SentenceBoundary(index=1, start_ms=5000, end_ms=12000, text="Unchanged.", language="en", reference_speech_duration_ms=6400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
repository.save_sentence_translations(VIDEO_HASH, {1: "未变化。"})
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class MustNotTranscribe:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def transcribe(self, audio_path, language=None):
|
||||||
|
calls.append(audio_path)
|
||||||
|
raise AssertionError("provisional save must not transcribe")
|
||||||
|
|
||||||
|
client = TestClient(
|
||||||
|
create_app(
|
||||||
|
BoundaryStore(tmp_path / "boundaries.json"),
|
||||||
|
settings=settings,
|
||||||
|
repository=repository,
|
||||||
|
transcriber=MustNotTranscribe(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/batch",
|
||||||
|
json={
|
||||||
|
"process_timings": False,
|
||||||
|
"sentences": [
|
||||||
|
{"start_ms": 0, "end_ms": 2500, "text": "First part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000},
|
||||||
|
{"start_ms": 2500, "end_ms": 5000, "text": "second part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000},
|
||||||
|
{"start_ms": 5000, "end_ms": 12000, "text": "Unchanged.", "language": "en", "zh_text": "未变化。", "reference_speech_duration_ms": 6400},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
payload = response.json()
|
||||||
|
assert [item["text"] for item in payload] == ["First part", "second part", "Unchanged."]
|
||||||
|
assert [item["start_ms"] for item in payload] == [0, 2500, 5000]
|
||||||
|
assert [item["zh_text"] for item in payload] == [None, None, "未变化。"]
|
||||||
|
assert calls == []
|
||||||
|
document = repository.get_document(VIDEO_HASH)
|
||||||
|
assert [item.text for item in document.sentences] == ["First part", "second part", "Unchanged."]
|
||||||
|
assert [item.zh_text for item in document.sentences] == [None, None, "未变化。"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sentence_merge_does_not_start_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)
|
||||||
requested = []
|
requested = []
|
||||||
@@ -966,66 +1032,15 @@ def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatc
|
|||||||
assert merge.status_code == 200, merge.text
|
assert merge.status_code == 200, merge.text
|
||||||
assert merge.json()["text"] == "Hello. Nice to meet you."
|
assert merge.json()["text"] == "Hello. Nice to meet you."
|
||||||
|
|
||||||
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
|
snapshot = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||||
assert finished.json()["complete"] is True
|
assert snapshot.status_code == 200
|
||||||
assert finished.json()["translations"] == [
|
assert snapshot.json()["status"] == "idle"
|
||||||
{"index": 0, "zh_text": "中文:Hello. Nice to meet you."},
|
assert snapshot.json()["complete"] is False
|
||||||
]
|
assert snapshot.json()["translations"] == []
|
||||||
|
assert len(requested) == 1
|
||||||
document = client.app.state.video_repository.get_document(VIDEO_HASH)
|
document = client.app.state.video_repository.get_document(VIDEO_HASH)
|
||||||
assert [sentence.text for sentence in document.sentences] == ["Hello. Nice to meet you."]
|
assert [sentence.text for sentence in document.sentences] == ["Hello. Nice to meet you."]
|
||||||
assert [sentence.zh_text for sentence in document.sentences] == ["中文:Hello. Nice to meet you."]
|
assert [sentence.zh_text for sentence in document.sentences] == [None]
|
||||||
assert requested[-1] == [(0, "Hello. Nice to meet you.")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_sentence_merge_drops_stale_in_flight_translation(tmp_path, monkeypatch):
|
|
||||||
client = make_client(tmp_path)
|
|
||||||
_ready_video(client.app.state.video_repository)
|
|
||||||
started = threading.Event()
|
|
||||||
release = threading.Event()
|
|
||||||
requested = []
|
|
||||||
|
|
||||||
def translate_sentences(sentences, **kwargs):
|
|
||||||
call_index = len(requested)
|
|
||||||
requested.append([(index, text) for index, text in sentences])
|
|
||||||
persist = kwargs.get("persist")
|
|
||||||
if call_index == 0:
|
|
||||||
if persist is not None:
|
|
||||||
persist({0: "旧0", 1: "旧1"})
|
|
||||||
started.set()
|
|
||||||
assert release.wait(timeout=5)
|
|
||||||
if persist is not None:
|
|
||||||
persist({0: "晚0", 1: "晚1"})
|
|
||||||
return {0: "晚0", 1: "晚1"}
|
|
||||||
return {index: f"新:{text}" for index, text in sentences}
|
|
||||||
|
|
||||||
class StubAITutor:
|
|
||||||
available = True
|
|
||||||
|
|
||||||
stub = StubAITutor()
|
|
||||||
stub.translate_sentences = translate_sentences
|
|
||||||
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
|
||||||
client.app.state.subtitle_translations.ai_tutor = stub
|
|
||||||
client.app.state.subtitle_translations.post_wait_seconds = 0.2
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
|
||||||
assert started.wait(timeout=2)
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
|
|
||||||
merge = client.put(f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/merge")
|
|
||||||
assert merge.status_code == 200, merge.text
|
|
||||||
release.set()
|
|
||||||
|
|
||||||
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
|
|
||||||
assert finished.json()["complete"] is True
|
|
||||||
assert finished.json()["translations"] == [
|
|
||||||
{"index": 0, "zh_text": "新:Hello. Nice to meet you."},
|
|
||||||
]
|
|
||||||
document = client.app.state.video_repository.get_document(VIDEO_HASH)
|
|
||||||
assert [sentence.zh_text for sentence in document.sentences] == [
|
|
||||||
"新:Hello. Nice to meet you."
|
|
||||||
]
|
|
||||||
assert requested[0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
|
||||||
assert requested[-1] == [(0, "Hello. Nice to meet you.")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path):
|
def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user