feat: CCA v6 腾讯云部署 + 审稿台(含查找替换)

- deploy/cca_route.py: Flask 蓝图(6个API端点),WAV自动转MP3
- deploy/cca.html: 4步单页流程(上传→处理→审稿→下载),查找替换(Ctrl+H)
- src/term_normalizer.py: 新增正则层(同音字/引号/书名号/小数点/波浪号)
- src/ai_proofreader.py: speaker角色识别+专家段增强Prompt+的地得加强
- src/ai_line_breaker.py: 引号不跨屏+极短行合并+短句合并间隔放宽
- cca_pipeline.py: Step 2.5 校对后二次正则兜底
- 已部署至 http://101.42.29.217/cca.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-05 21:44:52 +08:00
parent ede30d3043
commit 7eb6511169
9 changed files with 2311 additions and 90 deletions
+910
View File
@@ -0,0 +1,910 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCA 唱词助手 - 军事科技</title>
<link rel="stylesheet" href="styles.css">
<style>
/* CCA 专用样式 */
.cca-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.step-indicator {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
}
.step-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--border-color);
transition: all 0.3s;
}
.step-dot.active { background: var(--accent-primary); transform: scale(1.3); }
.step-dot.done { background: var(--success); }
.step-panel { display: none; flex: 1; flex-direction: column; }
.step-panel.active { display: flex; }
/* 上传区 */
.upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: var(--bg-secondary);
margin-bottom: 16px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent-primary);
background: rgba(99,102,241,0.05);
}
.upload-zone.has-file {
border-color: var(--success);
border-style: solid;
}
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
.upload-label { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 4px; }
.upload-hint { color: var(--text-muted); font-size: 0.8rem; }
.upload-filename {
color: var(--success); font-weight: 600;
font-size: 1rem; margin-top: 8px;
}
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
/* 进度 */
.progress-container {
text-align: center;
padding: 60px 20px;
}
.progress-spinner {
width: 60px; height: 60px;
border: 4px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 24px;
}
.progress-text {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 8px;
}
.progress-detail { color: var(--text-muted); font-size: 0.85rem; }
/* 审稿台 */
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.review-stats { display: flex; gap: 16px; font-size: 0.85rem; }
.review-stats span { color: var(--text-muted); }
.review-stats .count { color: var(--accent-secondary); font-weight: 600; }
.review-stats .changed { color: var(--warning); }
.review-controls {
display: flex; gap: 8px;
}
.review-list {
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 320px);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.review-item {
display: grid;
grid-template-columns: 60px 1fr 1fr 80px;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
align-items: center;
font-size: 0.9rem;
transition: background 0.15s;
}
.review-item:hover { background: var(--bg-tertiary); }
.review-item.changed { background: rgba(245,158,11,0.05); }
.review-item.confirmed { opacity: 0.7; }
.review-time {
font-size: 0.75rem;
color: var(--text-muted);
font-family: monospace;
}
.review-original {
color: var(--text-muted);
font-size: 0.85rem;
}
.review-edited {
color: var(--text-primary);
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: 4px;
outline: none;
font-size: 0.9rem;
font-family: inherit;
width: 100%;
}
.review-edited:focus {
border-color: var(--accent-primary);
background: var(--bg-card);
}
.review-item.changed .review-original {
text-decoration: line-through;
color: var(--error);
}
.review-item.changed .review-edited {
color: var(--success);
}
.review-confirm-btn {
width: 28px; height: 28px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--text-muted);
transition: all 0.15s;
}
.review-confirm-btn.checked {
background: var(--success);
border-color: var(--success);
color: white;
}
.review-confirm-btn:hover { border-color: var(--success); }
.review-legend {
display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted);
padding: 8px 0;
}
.legend-dot {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; margin-right: 4px; vertical-align: middle;
}
/* 查找替换面板 */
.replace-panel {
display: none;
background: var(--bg-secondary);
border: 1px solid var(--accent-primary);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 12px;
animation: fadeIn 0.2s;
}
.replace-panel.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.replace-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.replace-row:last-child { margin-bottom: 0; }
.replace-label {
font-size: 0.8rem;
color: var(--text-muted);
width: 40px;
flex-shrink: 0;
}
.replace-input {
flex: 1;
padding: 6px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.replace-input:focus { border-color: var(--accent-primary); }
.replace-actions {
display: flex;
gap: 6px;
align-items: center;
}
.replace-btn {
padding: 5px 10px;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
transition: all 0.15s;
white-space: nowrap;
}
.replace-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.replace-btn.primary { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
.replace-btn.primary:hover { opacity: 0.85; }
.replace-btn.danger { background: var(--warning); color: white; border-color: var(--warning); }
.replace-btn.danger:hover { opacity: 0.85; }
.replace-info {
font-size: 0.78rem;
color: var(--text-muted);
margin-left: 4px;
}
.replace-highlight {
background: rgba(245,158,11,0.3);
border-radius: 2px;
padding: 0 1px;
}
.replace-current {
background: rgba(99,102,241,0.4);
outline: 2px solid var(--accent-primary);
border-radius: 2px;
padding: 0 1px;
}
/* 过滤按钮 */
.filter-btn {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.filter-btn.active { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
/* 完成页 */
.done-container { text-align: center; padding: 60px 20px; }
.done-icon { font-size: 64px; margin-bottom: 16px; }
.done-title { font-size: 1.3rem; margin-bottom: 8px; }
.done-detail { color: var(--text-muted); margin-bottom: 24px; }
/* 错误 */
.error-box {
background: rgba(239,68,68,0.1);
border: 1px solid var(--error);
border-radius: var(--radius-md);
padding: 16px;
color: var(--error);
text-align: center;
margin-top: 16px;
}
.back-link {
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
}
.back-link:hover { color: var(--text-primary); }
.cca-badge {
padding: 8px 16px;
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
color: white;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--radius-full);
}
@media (max-width: 768px) {
.upload-grid { grid-template-columns: 1fr; }
.review-item { grid-template-columns: 50px 1fr 40px; }
.review-original { display: none; }
}
</style>
</head>
<body>
<div class="cca-container">
<header class="header">
<div class="logo">
<a href="index.html" class="back-link" style="margin-right:12px">← 返回配音</a>
<span class="logo-text">CCA <span class="logo-sub">唱词助手</span></span>
</div>
<div class="header-info">
<span class="cca-badge">唱词字幕</span>
</div>
</header>
<!-- 步骤指示器 -->
<div class="step-indicator">
<div class="step-dot active" id="dot-0"></div>
<div class="step-dot" id="dot-1"></div>
<div class="step-dot" id="dot-2"></div>
<div class="step-dot" id="dot-3"></div>
</div>
<!-- Step 0: 上传 -->
<div class="step-panel active" id="step-upload">
<div class="panel" style="flex:1; padding:24px;">
<h2 style="margin-bottom:8px;">上传素材</h2>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:24px;">
上传编导 A 稿和粗编人声音频,AI 将自动完成 ASR 转写、术语校对、的地得纠错和口头语清除
</p>
<div class="upload-grid">
<div class="upload-zone" id="zone-audio" onclick="document.getElementById('input-audio').click()">
<div class="upload-icon">&#127908;</div>
<div class="upload-label">人声音频</div>
<div class="upload-hint">MP3 / WAV,纯人声(必传)</div>
<div class="upload-filename" id="name-audio"></div>
<input type="file" id="input-audio" accept=".mp3,.wav,.m4a" style="display:none">
</div>
<div class="upload-zone" id="zone-script" onclick="document.getElementById('input-script').click()">
<div class="upload-icon">&#128196;</div>
<div class="upload-label">A 稿文件</div>
<div class="upload-hint">DOCX / TXT(强烈建议上传,用于术语校对)</div>
<div class="upload-filename" id="name-script"></div>
<input type="file" id="input-script" accept=".docx,.doc,.txt" style="display:none">
</div>
</div>
<div class="action-buttons" style="justify-content:center;">
<button class="btn btn-primary" id="btn-start" disabled style="max-width:300px;">
开始处理
</button>
</div>
</div>
</div>
<!-- Step 1: 处理中 -->
<div class="step-panel" id="step-processing">
<div class="panel" style="flex:1;">
<div class="progress-container">
<div class="progress-spinner"></div>
<div class="progress-text" id="progress-text">准备中...</div>
<div class="progress-detail" id="progress-detail">请勿关闭页面,ASR 转写通常需要 1-2 分钟</div>
</div>
</div>
</div>
<!-- Step 2: 审稿台 -->
<div class="step-panel" id="step-review">
<div class="review-header">
<div>
<span style="font-weight:600;">审稿台</span>
<span class="review-stats" style="margin-left:16px;">
总计 <span class="count" id="stat-total">0</span> 句 |
AI 修改 <span class="changed" id="stat-changed">0</span> 处 |
已确认 <span class="count" id="stat-confirmed">0</span>
</span>
</div>
<div class="review-controls">
<button class="filter-btn active" id="filter-all" onclick="filterItems('all')">全部</button>
<button class="filter-btn" id="filter-changed" onclick="filterItems('changed')">仅看修改</button>
<button class="replace-btn" onclick="toggleReplace()" id="btn-toggle-replace" title="Ctrl+H">查找替换</button>
<button class="btn btn-secondary" style="padding:6px 12px; font-size:0.8rem;" onclick="confirmAll()">全部确认</button>
<button class="btn btn-primary" id="btn-generate" style="padding:6px 16px; font-size:0.85rem;">
生成 SRT
</button>
</div>
</div>
<!-- 查找替换面板 -->
<div class="replace-panel" id="replace-panel">
<div class="replace-row">
<span class="replace-label">查找</span>
<input class="replace-input" id="replace-find" placeholder="输入要查找的文字..." oninput="onFindInput()">
<div class="replace-actions">
<span class="replace-info" id="replace-match-info"></span>
<button class="replace-btn" onclick="findPrev()" title="上一个">&#9650;</button>
<button class="replace-btn" onclick="findNext()" title="下一个">&#9660;</button>
</div>
</div>
<div class="replace-row">
<span class="replace-label">替换</span>
<input class="replace-input" id="replace-to" placeholder="替换为...">
<div class="replace-actions">
<button class="replace-btn primary" onclick="replaceCurrent()">替换当前</button>
<button class="replace-btn danger" onclick="replaceAll()">全部替换</button>
<button class="replace-btn" onclick="toggleReplace()">关闭</button>
</div>
</div>
</div>
<div class="review-legend">
<span><span class="legend-dot" style="background:var(--warning);"></span>AI 已修改</span>
<span><span class="legend-dot" style="background:var(--success);"></span>已确认</span>
<span>点击文字可直接编辑,右侧勾选确认</span>
</div>
<div class="review-list" id="review-list"></div>
</div>
<!-- Step 3: 完成 -->
<div class="step-panel" id="step-done">
<div class="panel" style="flex:1;">
<div class="done-container">
<div class="done-icon">&#9989;</div>
<div class="done-title">SRT 字幕文件生成完成</div>
<div class="done-detail" id="done-detail"></div>
<div class="action-buttons" style="justify-content:center; gap:12px;">
<button class="btn btn-primary" id="btn-download" style="max-width:200px;">
下载字幕包
</button>
<button class="btn btn-secondary" onclick="location.reload()" style="max-width:200px;">
处理下一期
</button>
</div>
</div>
</div>
</div>
<!-- 错误展示 -->
<div class="error-box" id="error-box" style="display:none;"></div>
</div>
<script>
const API = '/api/cca';
let currentTaskId = null;
let reviewData = [];
let currentFilter = 'all';
let pollTimer = null;
// === 文件上传 ===
const audioInput = document.getElementById('input-audio');
const scriptInput = document.getElementById('input-script');
const btnStart = document.getElementById('btn-start');
audioInput.addEventListener('change', () => {
const f = audioInput.files[0];
if (f) {
document.getElementById('name-audio').textContent = f.name;
document.getElementById('zone-audio').classList.add('has-file');
}
checkReady();
});
scriptInput.addEventListener('change', () => {
const f = scriptInput.files[0];
if (f) {
document.getElementById('name-script').textContent = f.name;
document.getElementById('zone-script').classList.add('has-file');
}
});
// 拖拽
['zone-audio', 'zone-script'].forEach(id => {
const zone = document.getElementById(id);
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const inputId = id === 'zone-audio' ? 'input-audio' : 'input-script';
const input = document.getElementById(inputId);
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
});
});
function checkReady() {
btnStart.disabled = !audioInput.files[0];
}
btnStart.addEventListener('click', async () => {
if (!audioInput.files[0]) return;
const formData = new FormData();
formData.append('audio', audioInput.files[0]);
if (scriptInput.files[0]) formData.append('script', scriptInput.files[0]);
btnStart.disabled = true;
btnStart.textContent = '上传中...';
try {
const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
const data = await res.json();
if (data.error) { showError(data.error); return; }
currentTaskId = data.task_id;
goToStep(1);
startPolling();
} catch (e) {
showError('上传失败: ' + e.message);
}
});
// === 轮询状态 ===
function startPolling() {
pollTimer = setInterval(async () => {
try {
const res = await fetch(`${API}/status/${currentTaskId}`);
const data = await res.json();
document.getElementById('progress-text').textContent = data.progress || '处理中...';
if (data.status === 'review') {
clearInterval(pollTimer);
await loadReview();
goToStep(2);
} else if (data.status === 'error') {
clearInterval(pollTimer);
showError(data.error || '处理出错');
}
} catch (e) {
console.error('poll error', e);
}
}, 2000);
}
// === 审稿台 ===
async function loadReview() {
const res = await fetch(`${API}/review/${currentTaskId}`);
const data = await res.json();
reviewData = data.items;
renderReview();
}
function msToTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const ss = String(s % 60).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0');
const hh = String(h).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function renderReview() {
const list = document.getElementById('review-list');
list.innerHTML = '';
let totalChanged = 0;
let totalConfirmed = 0;
reviewData.forEach((item, i) => {
if (item.has_change) totalChanged++;
if (item.confirmed) totalConfirmed++;
if (currentFilter === 'changed' && !item.has_change) return;
const row = document.createElement('div');
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
row.dataset.index = i;
row.innerHTML = `
<div class="review-time">${msToTime(item.start_ms)}</div>
<div class="review-original">${escHtml(item.original)}</div>
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}"
onfocus="this.parentElement.classList.remove('confirmed')"
onblur="onEditBlur(this)">
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
onclick="toggleConfirm(${i}, this)" title="确认">
${item.confirmed ? '&#10003;' : ''}
</button>
`;
list.appendChild(row);
});
document.getElementById('stat-total').textContent = reviewData.length;
document.getElementById('stat-changed').textContent = totalChanged;
document.getElementById('stat-confirmed').textContent = totalConfirmed;
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { return s.replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
function onEditBlur(input) {
const idx = parseInt(input.dataset.idx);
reviewData[idx].edited = input.value;
reviewData[idx].confirmed = true;
updateStats();
autoSave();
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
if (keyword) {
onFindInput();
} else {
renderReview();
}
}
function toggleConfirm(idx, btn) {
reviewData[idx].confirmed = !reviewData[idx].confirmed;
btn.classList.toggle('checked');
btn.innerHTML = reviewData[idx].confirmed ? '&#10003;' : '';
btn.parentElement.classList.toggle('confirmed', reviewData[idx].confirmed);
updateStats();
autoSave();
}
function confirmAll() {
reviewData.forEach(item => item.confirmed = true);
renderReview();
autoSave();
}
function filterItems(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
document.getElementById('filter-' + filter).classList.add('active');
renderReview();
}
function updateStats() {
let confirmed = reviewData.filter(i => i.confirmed).length;
document.getElementById('stat-confirmed').textContent = confirmed;
}
let saveTimer = null;
function autoSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
fetch(`${API}/save/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: reviewData }),
}).catch(console.error);
}, 1000);
}
// === 生成 SRT ===
document.getElementById('btn-generate').addEventListener('click', async () => {
const btn = document.getElementById('btn-generate');
btn.disabled = true;
btn.textContent = '生成中...';
// 先保存最新编辑
await fetch(`${API}/save/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: reviewData }),
});
try {
const res = await fetch(`${API}/generate/${currentTaskId}`, { method: 'POST' });
const data = await res.json();
if (data.error) { showError(data.error); btn.disabled = false; btn.textContent = '生成 SRT'; return; }
document.getElementById('done-detail').textContent = data.message;
goToStep(3);
} catch (e) {
showError('生成失败: ' + e.message);
btn.disabled = false;
btn.textContent = '生成 SRT';
}
});
// === 下载 ===
document.getElementById('btn-download').addEventListener('click', () => {
window.location.href = `${API}/download/${currentTaskId}`;
});
// === 步骤控制 ===
function goToStep(n) {
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
const panels = ['step-upload', 'step-processing', 'step-review', 'step-done'];
document.getElementById(panels[n]).classList.add('active');
for (let i = 0; i < 4; i++) {
const dot = document.getElementById('dot-' + i);
dot.className = 'step-dot' + (i === n ? ' active' : (i < n ? ' done' : ''));
}
document.getElementById('error-box').style.display = 'none';
}
function showError(msg) {
const box = document.getElementById('error-box');
box.textContent = msg;
box.style.display = 'block';
}
// === 查找替换 ===
let findMatches = []; // [{idx, pos}] — idx=reviewData index, pos=match position in text
let findCursor = -1;
function toggleReplace() {
const panel = document.getElementById('replace-panel');
const btn = document.getElementById('btn-toggle-replace');
panel.classList.toggle('active');
btn.classList.toggle('active');
if (panel.classList.contains('active')) {
document.getElementById('replace-find').focus();
} else {
clearFindHighlights();
findMatches = [];
findCursor = -1;
document.getElementById('replace-match-info').textContent = '';
}
}
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
e.preventDefault();
const panel = document.getElementById('replace-panel');
if (!panel.classList.contains('active') && document.getElementById('step-review').classList.contains('active')) {
toggleReplace();
} else if (panel.classList.contains('active')) {
document.getElementById('replace-find').focus();
}
}
});
function onFindInput() {
const keyword = document.getElementById('replace-find').value;
if (!keyword) {
findMatches = [];
findCursor = -1;
document.getElementById('replace-match-info').textContent = '';
renderReview();
return;
}
findMatches = [];
reviewData.forEach((item, idx) => {
let pos = 0;
const text = item.edited;
while (true) {
const found = text.indexOf(keyword, pos);
if (found === -1) break;
findMatches.push({ idx, pos: found });
pos = found + keyword.length;
}
});
findCursor = findMatches.length > 0 ? 0 : -1;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function findNext() {
if (findMatches.length === 0) return;
findCursor = (findCursor + 1) % findMatches.length;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function findPrev() {
if (findMatches.length === 0) return;
findCursor = (findCursor - 1 + findMatches.length) % findMatches.length;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function updateFindInfo() {
const info = document.getElementById('replace-match-info');
if (findMatches.length === 0) {
info.textContent = '无匹配';
info.style.color = 'var(--error)';
} else {
info.textContent = `${findCursor + 1} / ${findMatches.length}`;
info.style.color = 'var(--text-muted)';
}
}
function replaceCurrent() {
if (findCursor < 0 || findCursor >= findMatches.length) return;
const keyword = document.getElementById('replace-find').value;
const replacement = document.getElementById('replace-to').value;
if (!keyword) return;
const match = findMatches[findCursor];
const item = reviewData[match.idx];
item.edited = item.edited.substring(0, match.pos) + replacement + item.edited.substring(match.pos + keyword.length);
item.confirmed = true;
onFindInput();
autoSave();
}
function replaceAll() {
const keyword = document.getElementById('replace-find').value;
const replacement = document.getElementById('replace-to').value;
if (!keyword) return;
if (findMatches.length === 0) return;
const count = findMatches.length;
const affectedIdxs = new Set(findMatches.map(m => m.idx));
affectedIdxs.forEach(idx => {
const item = reviewData[idx];
item.edited = item.edited.split(keyword).join(replacement);
item.confirmed = true;
});
onFindInput();
autoSave();
document.getElementById('replace-match-info').textContent = `已替换 ${count}`;
document.getElementById('replace-match-info').style.color = 'var(--success)';
}
function clearFindHighlights() {
renderReview();
}
function scrollToCurrentMatch() {
if (findCursor < 0 || findCursor >= findMatches.length) return;
const match = findMatches[findCursor];
const row = document.querySelector(`.review-item[data-index="${match.idx}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// 重写 renderReview 支持查找高亮
const _origRenderReview = renderReview;
renderReview = function() {
const list = document.getElementById('review-list');
list.innerHTML = '';
let totalChanged = 0;
let totalConfirmed = 0;
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
const currentMatch = findCursor >= 0 && findCursor < findMatches.length ? findMatches[findCursor] : null;
// 建一个快速查找表:哪些 (idx, pos) 是当前光标
let globalMatchIdx = 0;
reviewData.forEach((item, i) => {
if (item.has_change) totalChanged++;
if (item.confirmed) totalConfirmed++;
if (currentFilter === 'changed' && !item.has_change) return;
const row = document.createElement('div');
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
row.dataset.index = i;
// 高亮匹配关键词
let editedDisplay = escHtml(item.edited);
if (keyword && item.edited.includes(keyword)) {
let result = '';
let searchPos = 0;
const text = item.edited;
while (true) {
const found = text.indexOf(keyword, searchPos);
if (found === -1) {
result += escHtml(text.substring(searchPos));
break;
}
result += escHtml(text.substring(searchPos, found));
const isCurrent = currentMatch && currentMatch.idx === i && currentMatch.pos === found;
result += `<span class="${isCurrent ? 'replace-current' : 'replace-highlight'}">${escHtml(keyword)}</span>`;
searchPos = found + keyword.length;
}
editedDisplay = result;
}
row.innerHTML = `
<div class="review-time">${msToTime(item.start_ms)}</div>
<div class="review-original">${escHtml(item.original)}</div>
<div style="position:relative;">
<div class="review-edited-display" style="padding:4px 8px;font-size:0.9rem;min-height:1.5em;cursor:text;border:1px solid transparent;border-radius:4px;background:var(--bg-tertiary);"
onclick="startEdit(this)">${editedDisplay}</div>
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}" style="display:none;"
onblur="onEditBlur(this)">
</div>
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
onclick="toggleConfirm(${i}, this)" title="确认">
${item.confirmed ? '&#10003;' : ''}
</button>
`;
list.appendChild(row);
});
document.getElementById('stat-total').textContent = reviewData.length;
document.getElementById('stat-changed').textContent = totalChanged;
document.getElementById('stat-confirmed').textContent = totalConfirmed;
};
function startEdit(displayDiv) {
displayDiv.style.display = 'none';
const input = displayDiv.nextElementSibling;
input.style.display = '';
input.focus();
}
</script>
</body>
</html>
+330
View File
@@ -0,0 +1,330 @@
# -*- coding: utf-8 -*-
"""
CCA 唱词助手 — Flask 路由
POST /api/cca/upload 上传 A稿+音频,启动流水线
GET /api/cca/status/<id> 轮询任务状态
GET /api/cca/review/<id> 获取审稿数据
POST /api/cca/save/<id> 保存编导修改
POST /api/cca/generate/<id> 生成最终 SRT
GET /api/cca/download/<id> 下载 SRT zip
"""
import json
import os
import sys
import uuid
import threading
import traceback
import zipfile
from io import BytesIO
from pathlib import Path
from datetime import datetime
from flask import Blueprint, request, jsonify, send_file
bp = Blueprint('cca', __name__, url_prefix='/api/cca')
# 运行时数据目录
CCA_DATA_DIR = Path('/workspace/military_tech_voice/backend/cca_data')
CCA_DATA_DIR.mkdir(parents=True, exist_ok=True)
# CCA 源码目录
CCA_SRC_DIR = Path('/workspace/military_tech_voice/backend/cca_src')
if str(CCA_SRC_DIR) not in sys.path:
sys.path.insert(0, str(CCA_SRC_DIR))
# 任务状态存储(内存,重启丢失无所谓——编导重新上传即可)
tasks = {}
def _get_task(task_id):
t = tasks.get(task_id)
if not t:
return None
return t
@bp.route('/upload', methods=['POST'])
def upload():
"""接收 A稿 + 音频,创建任务"""
if 'audio' not in request.files:
return jsonify({'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
script_file = request.files.get('script')
if not audio_file.filename:
return jsonify({'error': '音频文件为空'}), 400
task_id = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:6]
task_dir = CCA_DATA_DIR / task_id
task_dir.mkdir(parents=True, exist_ok=True)
# 保存音频
audio_ext = os.path.splitext(audio_file.filename)[1] or '.mp3'
audio_path = task_dir / f'audio{audio_ext}'
audio_file.save(str(audio_path))
# 保存 A稿
script_path = None
if script_file and script_file.filename:
script_ext = os.path.splitext(script_file.filename)[1] or '.docx'
script_path = task_dir / f'script{script_ext}'
script_file.save(str(script_path))
tasks[task_id] = {
'id': task_id,
'status': 'uploaded',
'progress': '文件已上传,准备处理...',
'audio_path': str(audio_path),
'script_path': str(script_path) if script_path else None,
'created_at': datetime.now().isoformat(),
'error': None,
'asr_sentences': None,
'proofread_sentences': None,
'review_data': None,
'final_srt_dir': None,
}
# 启动后台处理
thread = threading.Thread(target=_run_pipeline, args=(task_id,), daemon=True)
thread.start()
return jsonify({'task_id': task_id, 'status': 'processing'})
def _run_pipeline(task_id):
"""后台运行 CCA 流水线"""
task = tasks[task_id]
try:
task['status'] = 'processing'
task['progress'] = '正在提取热词...'
audio_path = task['audio_path']
script_path = task['script_path']
# WAV/大文件 → MP3 压缩(讯飞上传大文件容易超时)
if audio_path.lower().endswith('.wav'):
import subprocess
mp3_path = audio_path.rsplit('.', 1)[0] + '.mp3'
task['progress'] = '正在压缩音频(WAV→MP3...'
subprocess.run(
['ffmpeg', '-i', audio_path, '-b:a', '128k', '-y', mp3_path],
capture_output=True, timeout=300,
)
if os.path.exists(mp3_path) and os.path.getsize(mp3_path) > 0:
audio_path = mp3_path
task['audio_path'] = mp3_path
from hotword_extractor import extract_hotwords, read_docx_text, read_text_file
from asr_client import transcribe, parse_result
from term_normalizer import normalize_terms
from ai_proofreader import proofread_batch
from segment_splitter import split_into_segments
# Step 0: 热词提取
hot_words = []
script_text = ""
if script_path:
hot_words = extract_hotwords(script_path, use_ai=True)
ext = os.path.splitext(script_path)[1].lower()
if ext == '.docx':
script_text = read_docx_text(script_path)
else:
script_text = read_text_file(script_path)
task['progress'] = f'热词提取完成({len(hot_words)}个),正在 ASR 转写...'
# Step 1: ASR
sentences, raw_json = transcribe(audio_path, hot_words=hot_words if hot_words else None)
# 缓存 ASR 原始结果
task_dir = Path(audio_path).parent
cache_path = task_dir / 'asr_raw.json'
with open(cache_path, 'w', encoding='utf-8') as f:
f.write(raw_json)
task['progress'] = f'ASR 完成({len(sentences)}句),正在术语格式化...'
# 保存 ASR 原始句子(校对前,供 diff 对比)
asr_original = [(bg, ed, text, spk) for bg, ed, text, spk in sentences]
# Step 1.5: 术语格式化
if script_text:
sentences = normalize_terms(sentences, script_text)
task['progress'] = '术语格式化完成,正在 AI 校对...'
# Step 2: AI 校对
if script_text:
sentences = proofread_batch(sentences, script_text)
# Step 2.5: 校对后二次正则
if script_text:
sentences = normalize_terms(sentences, script_text)
task['progress'] = 'AI 校对完成,正在准备审稿数据...'
# 保存校对后的句子
task['asr_sentences'] = asr_original
task['proofread_sentences'] = sentences
# 构建审稿数据:逐句对比
review_items = []
for i, ((bg, ed, orig_text, spk), (_, _, proof_text, _)) in enumerate(
zip(asr_original, sentences)
):
has_change = orig_text != proof_text
review_items.append({
'index': i,
'start_ms': bg,
'end_ms': ed,
'speaker_id': spk,
'original': orig_text,
'corrected': proof_text,
'edited': proof_text,
'has_change': has_change,
'confirmed': not has_change,
})
task['review_data'] = review_items
task['status'] = 'review'
task['progress'] = '审稿数据就绪,请编导审阅确认'
# 同时保存到磁盘(防丢)
review_path = task_dir / 'review_data.json'
with open(review_path, 'w', encoding='utf-8') as f:
json.dump(review_items, f, ensure_ascii=False, indent=2)
except Exception as e:
task['status'] = 'error'
err_msg = str(e)
if '余额不足' in err_msg or 'insufficient' in err_msg.lower() or '10317' in err_msg:
task['error'] = '讯飞录音文件转写余额不足,请联系管理员充值'
else:
task['error'] = f'处理出错: {err_msg}'
task['progress'] = task['error']
traceback.print_exc()
@bp.route('/status/<task_id>', methods=['GET'])
def status(task_id):
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
return jsonify({
'task_id': task_id,
'status': task['status'],
'progress': task['progress'],
'error': task['error'],
})
@bp.route('/review/<task_id>', methods=['GET'])
def review(task_id):
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if task['status'] not in ('review', 'completed'):
return jsonify({'error': '任务尚未就绪', 'status': task['status']}), 400
return jsonify({
'task_id': task_id,
'items': task['review_data'],
})
@bp.route('/save/<task_id>', methods=['POST'])
def save(task_id):
"""保存编导的修改(自动保存用)"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
data = request.get_json()
edits = data.get('items', [])
for edit in edits:
idx = edit.get('index')
if idx is not None and 0 <= idx < len(task['review_data']):
task['review_data'][idx]['edited'] = edit.get('edited', task['review_data'][idx]['edited'])
task['review_data'][idx]['confirmed'] = edit.get('confirmed', True)
return jsonify({'ok': True})
@bp.route('/generate/<task_id>', methods=['POST'])
def generate(task_id):
"""用编导确认后的文本生成最终 SRT"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if task['status'] not in ('review', 'completed'):
return jsonify({'error': '任务状态不对'}), 400
try:
from ai_line_breaker import process_sentences_with_ai
from srt_writer import write_srt, ms_to_srt_time
from segment_splitter import split_into_segments
# 用编导确认后的文本重建句子列表
confirmed_sentences = []
for item in task['review_data']:
text = item['edited']
confirmed_sentences.append((
item['start_ms'], item['end_ms'], text, item['speaker_id']
))
# 切分节目结构
segments = split_into_segments(confirmed_sentences)
# 折行 + 生成 SRT
task_dir = Path(task['audio_path']).parent
srt_dir = task_dir / 'srt_output'
srt_dir.mkdir(exist_ok=True)
srt_files = []
for seg_name, seg_sentences in segments:
if not seg_sentences:
continue
subtitle_lines = process_sentences_with_ai(seg_sentences)
srt_path = srt_dir / f'{seg_name}.srt'
write_srt(subtitle_lines, str(srt_path))
srt_files.append(str(srt_path))
task['final_srt_dir'] = str(srt_dir)
task['status'] = 'completed'
task['progress'] = f'生成完成,共 {len(srt_files)} 个 SRT 文件'
return jsonify({
'ok': True,
'srt_count': len(srt_files),
'message': task['progress'],
})
except Exception as e:
return jsonify({'error': f'生成 SRT 出错: {str(e)}'}), 500
@bp.route('/download/<task_id>', methods=['GET'])
def download(task_id):
"""下载 SRT zip 包"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if not task.get('final_srt_dir'):
return jsonify({'error': 'SRT 尚未生成'}), 400
srt_dir = Path(task['final_srt_dir'])
srt_files = sorted(srt_dir.glob('*.srt'))
if not srt_files:
return jsonify({'error': '无 SRT 文件'}), 404
# 打包 zip
buf = BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for srt_file in srt_files:
zf.write(srt_file, srt_file.name)
buf.seek(0)
filename = f'唱词字幕_{task_id}.zip'
return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=filename)
+246
View File
@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""
CCA 部署脚本 — 通过 paramiko 上传文件到腾讯云服务器
"""
import sys
sys.stdout.reconfigure(encoding='utf-8')
import os
import paramiko
from pathlib import Path
HOST = '101.42.29.217'
PORT = 22
USER = 'root'
PASS = 'liutong65'
CCA_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = CCA_ROOT / 'src'
DEPLOY_DIR = CCA_ROOT / 'deploy'
# 服务器目标路径
SERVER_CCA_SRC = '/workspace/military_tech_voice/backend/cca_src'
SERVER_FRONTEND = '/workspace/military_tech_voice/frontend'
SERVER_WWW = '/var/www/voice'
SERVER_BACKEND = '/workspace/military_tech_voice/backend'
SERVER_ROUTES = f'{SERVER_BACKEND}/app/routes'
# 需要上传的 src 模块
SRC_MODULES = [
'asr_client.py',
'line_breaker.py',
'ai_line_breaker.py',
'ai_proofreader.py',
'term_normalizer.py',
'hotword_extractor.py',
'srt_writer.py',
'segment_splitter.py',
]
def connect():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, PORT, USER, PASS)
sftp = ssh.open_sftp()
return ssh, sftp
def run(ssh, cmd):
print(f' $ {cmd}')
_, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode('utf-8', errors='replace').strip()
err = stderr.read().decode('utf-8', errors='replace').strip()
if out:
print(f' {out[:500]}')
if err:
print(f' [stderr] {err[:500]}')
return out
def upload(sftp, local_path, remote_path):
print(f'{Path(local_path).name}{remote_path}')
sftp.put(str(local_path), remote_path)
def main():
print('=== CCA 部署开始 ===\n')
ssh, sftp = connect()
print('[1/7] 连接成功\n')
# Step 2: 创建 cca_src 目录 + 上传源码
print('[2/7] 上传 CCA 源码模块...')
run(ssh, f'mkdir -p {SERVER_CCA_SRC}')
for module in SRC_MODULES:
local = SRC_DIR / module
if local.exists():
upload(sftp, local, f'{SERVER_CCA_SRC}/{module}')
else:
print(f' ⚠ 跳过(不存在): {module}')
print()
# Step 3: 上传 cca_route.py 到 app/routes/
print('[3/7] 上传 cca_route.py...')
run(ssh, f'mkdir -p {SERVER_ROUTES}')
upload(sftp, DEPLOY_DIR / 'cca_route.py', f'{SERVER_ROUTES}/cca.py')
# 确保 __init__.py 存在
run(ssh, f'touch {SERVER_ROUTES}/__init__.py')
print()
# Step 4: 上传 cca.html 到前端目录
print('[4/7] 上传 cca.html...')
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_FRONTEND}/cca.html')
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_WWW}/cca.html')
print()
# Step 5: 配置 .env(追加 CCA 相关变量)
print('[5/7] 配置 .env...')
env_path = f'{SERVER_BACKEND}/.env'
existing_env = ''
try:
with sftp.open(env_path, 'r') as f:
existing_env = f.read().decode('utf-8', errors='replace')
except FileNotFoundError:
pass
env_additions = []
if 'XFYUN_APP_ID' not in existing_env:
env_additions.append('# === CCA 唱词助手凭证 ===')
env_additions.append('# 讯飞录音文件转写(账号1-默认)')
env_additions.append('XFYUN_APP_ID=4c423e35')
env_additions.append('XFYUN_SECRET_KEY=b9e0b97d5dda072c9b4b8fb59e7e3d22')
env_additions.append('# 讯飞备用账号2')
env_additions.append('# XFYUN_APP_ID=52ae3024')
env_additions.append('# XFYUN_SECRET_KEY=d65de0eb282a4339e2b1e14fd119e42e')
if 'DEEPSEEK_API_KEY' not in existing_env:
env_additions.append('# DeepSeek(校对+折行+热词)')
env_additions.append('DEEPSEEK_API_KEY=sk-01a7868a88a04ab494e4f05c1f3f06e2')
if env_additions:
with sftp.open(env_path, 'a') as f:
f.write('\n' + '\n'.join(env_additions) + '\n')
print(' .env 已追加 CCA 凭证')
else:
print(' .env 已有 CCA 凭证,跳过')
print()
# Step 6: 注册 CCA 蓝图到 Flask main.py
print('[6/7] 注册 CCA 蓝图...')
main_py_path = f'{SERVER_BACKEND}/app/main.py'
with sftp.open(main_py_path, 'r') as f:
main_content = f.read().decode('utf-8', errors='replace')
if 'cca' not in main_content.lower():
# 找到最后一个 register_blueprint 的位置,在其后追加
lines = main_content.split('\n')
insert_idx = -1
for i, line in enumerate(lines):
if 'register_blueprint' in line:
insert_idx = i
if insert_idx >= 0:
indent = ' ' # 匹配现有缩进
# 检查现有蓝图注册的缩进
existing_line = lines[insert_idx]
indent = existing_line[:len(existing_line) - len(existing_line.lstrip())]
cca_lines = [
'',
f'{indent}# CCA 唱词助手',
f'{indent}from app.routes.cca import bp as cca_bp',
f'{indent}app.register_blueprint(cca_bp)',
]
for j, cca_line in enumerate(cca_lines):
lines.insert(insert_idx + 1 + j, cca_line)
new_content = '\n'.join(lines)
with sftp.open(main_py_path, 'w') as f:
f.write(new_content)
print(' 已注册 CCA 蓝图')
else:
print(' ⚠ 未找到 register_blueprint,请手动注册')
else:
print(' CCA 蓝图已注册,跳过')
print()
# Step 7: 安装依赖 + 修改 index.html + 重启
print('[7/7] 安装依赖、修改首页、重启服务...')
# 安装 Python 依赖
run(ssh, f'{SERVER_BACKEND}/venv/bin/pip install openai python-docx 2>&1 | tail -5')
# 修改 index.html 添加 CCA 入口按钮
index_path = f'{SERVER_FRONTEND}/index.html'
with sftp.open(index_path, 'r') as f:
index_content = f.read().decode('utf-8', errors='replace')
if '唱词助手' not in index_content and 'cca.html' not in index_content:
# 在导航栏中找到合适位置插入按钮
# 典型位置:header 或 nav 区域的最后一个链接之后
if '<header' in index_content or '<nav' in index_content:
# 找 </header> 或 </nav> 前插入
for marker in ['</nav>', '</header>']:
if marker in index_content:
btn_html = ' <a href="cca.html" class="nav-link">唱词助手</a>\n'
index_content = index_content.replace(marker, btn_html + ' ' + marker)
break
else:
# 备用:在 body 开头加一个浮动按钮
btn_html = '<div style="position:fixed;top:10px;right:10px;z-index:9999"><a href="cca.html" style="background:#4a9eff;color:#fff;padding:8px 16px;border-radius:6px;text-decoration:none;font-size:14px">唱词助手</a></div>\n'
index_content = index_content.replace('<body>', '<body>\n' + btn_html)
with sftp.open(index_path, 'w') as f:
f.write(index_content)
# 同步到 /var/www/voice/
run(ssh, f'cp {SERVER_FRONTEND}/index.html {SERVER_WWW}/index.html')
print(' index.html 已添加唱词助手入口')
else:
print(' index.html 已有唱词助手入口,跳过')
# 增大 Nginx 上传限制(音频文件可能较大)
nginx_conf = '/etc/nginx/nginx.conf'
with sftp.open(nginx_conf, 'r') as f:
nginx_content = f.read().decode('utf-8', errors='replace')
if 'client_max_body_size' not in nginx_content:
nginx_content = nginx_content.replace(
'http {',
'http {\n client_max_body_size 200m;'
)
with sftp.open(nginx_conf, 'w') as f:
f.write(nginx_content)
run(ssh, 'nginx -t && nginx -s reload')
print(' Nginx 已增大上传限制至 200MB')
else:
print(' Nginx 上传限制已配置')
# 重启 Flask 服务
print('\n 重启 Flask...')
# 查找并重启 Flask 进程
run(ssh, 'pkill -f "flask run" || pkill -f "gunicorn" || pkill -f "python.*app" || true')
# 给进程时间退出
import time
time.sleep(2)
# 检查服务启动方式
service_out = run(ssh, 'systemctl list-units --type=service | grep -i voice || systemctl list-units --type=service | grep -i flask || true')
if 'voice' in service_out.lower() or 'flask' in service_out.lower():
service_name = service_out.split()[0] if service_out else ''
if service_name:
run(ssh, f'systemctl restart {service_name}')
else:
# 直接后台启动
run(ssh, f'cd {SERVER_BACKEND} && source venv/bin/activate && nohup python -m flask run --host=0.0.0.0 --port=5000 > /tmp/flask_cca.log 2>&1 &')
print()
print('=== CCA 部署完成 ===')
print(f'访问地址: http://{HOST}/cca.html')
print(f'API 地址: http://{HOST}/api/cca/')
sftp.close()
ssh.close()
if __name__ == '__main__':
main()