+
+
+
+
+
+ ${issues.length ? escapeHtml(issues.join(" · ")) : "时间与阅读速度正常"}
+ diff --git a/.codemap/codemap.html b/.codemap/codemap.html new file mode 100644 index 0000000..23a81a2 --- /dev/null +++ b/.codemap/codemap.html @@ -0,0 +1,634 @@ + + + +
+ + +没有差异可接受。
'; + elements.aiAccept.disabled = diff.length === 0; + } + + elements.aiSelectAll?.addEventListener("click", () => { + elements.aiDiffList.querySelectorAll("[data-ai-diff-cue]").forEach((input) => { input.checked = true; }); + }); + + elements.aiClose?.addEventListener("click", () => { + elements.aiPanel.hidden = true; + state.suggestion = null; + }); + + elements.aiAccept?.addEventListener("click", async () => { + if (!state.suggestion || !state.revision) return; + const cueIds = [...elements.aiDiffList.querySelectorAll("[data-ai-diff-cue]:checked")].map((input) => input.value); + if (!cueIds.length) return setStatus("请至少勾选一条 AI 文字建议", "amber"); + elements.aiAccept.disabled = true; + try { + const payload = await api( + `/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/ai-suggestions/${encodeURIComponent(state.suggestion.revision.id)}/accept`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision_id: state.suggestion.base_revision_id, cue_ids: cueIds }), + }, + ); + state.suggestion = null; + elements.aiPanel.hidden = true; + await loadTrack(state.track.id); + setStatus(`已接受 ${cueIds.length} 条建议并创建人工草稿 Revision ${payload.revision.revision_number}`, "green"); + } catch (error) { + setStatus(`接受 AI 建议失败:${error.message}`, "red"); + elements.aiAccept.disabled = false; + } + }); + + elements.importFile.addEventListener("change", async () => { + const file = elements.importFile.files?.[0]; + if (!file || !state.track) return; + const form = new FormData(); + form.append("file", file); + setStatus(`正在导入 ${file.name}…`, "blue"); + try { + await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/import`, { method: "POST", body: form }); + await loadTrack(state.track.id); + } catch (error) { + setStatus(`导入失败:${error.message}`, "red"); + } finally { + elements.importFile.value = ""; + } + }); + + elements.exports.forEach(([format, button]) => button.addEventListener("click", () => { + if (!state.track || !state.revision) return; + window.location.href = `/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/export?format_name=${format}&revision_id=${encodeURIComponent(state.revision.id)}`; + })); + + function renderBatchJob(job) { + if (!batch.panel || !job) return; + batch.panel.hidden = false; + batch.jobId = job.id; + const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); + batch.bar.style.width = `${progress}%`; + batch.label.textContent = `${job.status_label || job.status} · ${progress}%`; + batch.message.textContent = job.error_message || job.message || "字幕任务状态已更新"; + const active = job.status === "queued" || job.status === "running"; + batch.cancel.hidden = !active; + batch.retry.hidden = !(job.status === "failed" || job.status === "cancelled"); + batch.approve.disabled = active; + batch.skip.disabled = active; + } + + async function pollBatchJob(jobId) { + window.clearTimeout(batch.pollTimer); + try { + const job = await api(`/api/tasks/jobs/${encodeURIComponent(jobId)}`); + renderBatchJob(job); + if (job.status === "queued" || job.status === "running") { + batch.pollTimer = window.setTimeout(() => pollBatchJob(jobId), 1500); + } else if (job.status === "completed") { + batch.message.textContent = "字幕成片全部验证通过,自动流水线已恢复。即将返回任务详情。"; + window.setTimeout(() => { window.location.href = `/tasks/${encodeURIComponent(state.taskId)}`; }, 1600); + } + } catch (error) { + batch.message.textContent = `读取字幕 Job 失败:${error.message}`; + batch.pollTimer = window.setTimeout(() => pollBatchJob(jobId), 3000); + } + } + + batch.approve?.addEventListener("click", async () => { + if (state.dirty) await saveRevision(false); + if (state.dirty) return; + if (!window.confirm("确认审核所有切片的当前字幕版本并批量烧录吗?全部验证通过后流水线会自动继续。")) return; + batch.approve.disabled = true; + batch.skip.disabled = true; + try { + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/approve-and-render`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approve_active_revisions: true }), + }); + renderBatchJob(payload.job); + pollBatchJob(payload.job_id); + } catch (error) { + batch.message.textContent = `批量烧录未启动:${error.message}`; + batch.panel.hidden = false; + batch.approve.disabled = false; + batch.skip.disabled = false; + } + }); + + batch.skip?.addEventListener("click", async () => { + if (!window.confirm("确认跳过字幕并进入片段审核吗?审核保存后才会同步发送中心。")) return; + batch.skip.disabled = true; + try { + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/skip-to-review`, { method: "POST" }); + window.location.href = payload.review_url || `/tasks/${encodeURIComponent(state.taskId)}/clips/review`; + } catch (error) { + batch.message.textContent = `跳过字幕失败:${error.message}`; + batch.panel.hidden = false; + batch.skip.disabled = false; + } + }); + + batch.cancel?.addEventListener("click", async () => { + if (!batch.jobId || !window.confirm("确认取消当前字幕烧录吗?已完成并验证的切片会保留,可稍后重试缺失部分。")) return; + const payload = await api(`/api/tasks/jobs/${encodeURIComponent(batch.jobId)}/cancel`, { method: "POST" }); + renderBatchJob(payload.job); + pollBatchJob(batch.jobId); + }); + + batch.retry?.addEventListener("click", async () => { + if (!batch.jobId) return; + const payload = await api(`/api/tasks/jobs/${encodeURIComponent(batch.jobId)}/retry`, { method: "POST" }); + renderBatchJob(payload.job); + pollBatchJob(batch.jobId); + }); + + async function loadLatestBatchJob() { + if (!batch.root) return; + try { + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/jobs`); + const latest = (payload.jobs || [])[0]; + if (latest) { + renderBatchJob(latest); + if (latest.status === "queued" || latest.status === "running") pollBatchJob(latest.id); + } + } catch (_error) { + // 页面仍可编辑字幕;Job 状态读取失败时由用户再次点击触发明确错误。 + } + } + + window.addEventListener("beforeunload", (event) => { + if (!state.dirty) return; + event.preventDefault(); + event.returnValue = ""; + }); + + loadTracks(); + loadLatestBatchJob(); +})(); diff --git a/app/static/vendor/wavesurfer/LICENSE b/app/static/vendor/wavesurfer/LICENSE new file mode 100644 index 0000000..88998ae --- /dev/null +++ b/app/static/vendor/wavesurfer/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2012-2023, katspaugh and contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/app/static/vendor/wavesurfer/regions.min.js b/app/static/vendor/wavesurfer/regions.min.js new file mode 100644 index 0000000..ff8303e --- /dev/null +++ b/app/static/vendor/wavesurfer/regions.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):((t="undefined"!=typeof globalThis?globalThis:t||self).WaveSurfer=t.WaveSurfer||{},t.WaveSurfer.Regions=e())}(this,(function(){"use strict";class t{constructor(){this.listeners={}}on(t,e,i){if(this.listeners[t]||(this.listeners[t]=new Set),null==i?void 0:i.once){const i=(...n)=>{this.un(t,i),e(...n)};return this.listeners[t].add(i),()=>this.un(t,i)}return this.listeners[t].add(e),()=>this.un(t,e)}un(t,e){var i;null===(i=this.listeners[t])||void 0===i||i.delete(e)}once(t,e){return this.on(t,e,{once:!0})}unAll(){this.listeners={}}emit(t,...e){this.listeners[t]&&this.listeners[t].forEach((t=>t(...e)))}}class e extends t{constructor(t){super(),this.subscriptions=[],this.isDestroyed=!1,this.options=t}onInit(){}_init(t){this.isDestroyed&&(this.subscriptions=[],this.isDestroyed=!1),this.wavesurfer=t,this.onInit()}destroy(){this.emit("destroy"),this.subscriptions.forEach((t=>t())),this.subscriptions=[],this.isDestroyed=!0,this.wavesurfer=void 0}}function i(t,e){const n=e.xmlns?document.createElementNS(e.xmlns,t):document.createElement(t);for(const[t,s]of Object.entries(e))if("children"===t&&s)for(const[t,e]of Object.entries(s))e instanceof Node?n.appendChild(e):"string"==typeof e?n.appendChild(document.createTextNode(e)):n.appendChild(i(t,e));else"style"===t?Object.assign(n.style,s):"textContent"===t?n.textContent=s:n.setAttribute(t,s.toString());return n}function n(t,e,n){const s=i(t,e||{});return null==n||n.appendChild(s),s}function s(t){let e=t;const i=new Set;return{get value(){return e},set(t){Object.is(e,t)||(e=t,i.forEach((t=>t(e))))},update(t){this.set(t(e))},subscribe:t=>(i.add(t),()=>i.delete(t))}}function r(t,e){let i;const n=()=>{i&&(i(),i=void 0),i=t()},s=e.map((t=>t.subscribe(n)));return n(),()=>{i&&(i(),i=void 0),s.forEach((t=>t()))}}function o(t,e){const i=s(null),n=t=>{i.set(t)};return t.addEventListener(e,n),i._cleanup=()=>{t.removeEventListener(e,n)},i}function l(t){const e=t._cleanup;"function"==typeof e&&e()}function h(t,e={}){const{threshold:i=3,mouseButton:n=0,touchDelay:r=100}=e,o=s(null),h=new Map,a=matchMedia("(pointer: coarse)").matches;let d=()=>{};const c=e=>{if(e.button!==n)return;if(h.has(e.pointerId))return;if(h.set(e.pointerId,e),h.size>1)return;const s=e.pointerId;let l=e.clientX,c=e.clientY,u=!1;const p=Date.now(),v=t.getBoundingClientRect(),{left:g,top:m}=v,f=t=>{if(t.pointerId!==s)return;if(t.defaultPrevented||h.size>1)return;if(a&&Date.now()-pClip Review
按工作流查看每个任务是否已经进入 AI 分析、人工审核、切片生成或异常排查阶段。
+集中查看累计审核任务、通过视频和已完成任务,并继续进入单任务审核。
Workflow
-Weekly overview
+周一至周日统计,发布终态内容不会重复计入待推送。
Today
-Review Gate
+请先检查和编辑字幕。点击“审核并批量烧录”会批准每条切片的当前版本,全部成片验证通过后才恢复发送中心流程。
+Professional Subtitle Editor
+先在原片修正统一字幕,再按切片边界自动继承;已经人工编辑的切片不会被覆盖。
+AI Suggestions
+勾选字幕行后生成建议;时间戳和说话人不会交给 AI 修改。
+样式会保存到 SQLite,后续自动加字幕会使用这套模板。
@@ -304,4 +448,10 @@