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

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