Commit 62c3f146 authored by liujiangnan's avatar liujiangnan

feat

parent 2a2fc7b2
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
margin: 0 auto; margin: 0 auto;
padding: 30px; padding: 30px;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); background: #fff;
min-height: 100vh; min-height: 100vh;
} }
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
padding: 40px 20px; padding: 40px 20px;
border: 3px dashed #d9d9d9; border: 3px dashed #d9d9d9;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); background: #fff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
...@@ -76,7 +76,7 @@ ...@@ -76,7 +76,7 @@
.video-section { .video-section {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); background: #fff;
border-radius: 16px; border-radius: 16px;
padding: 30px; padding: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
...@@ -114,7 +114,7 @@ ...@@ -114,7 +114,7 @@
#timeline { #timeline {
width: 100%; width: 100%;
height: 40px; height: 40px;
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%); background: #fff;
border-radius: 6px; border-radius: 6px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
padding: 5px 0; padding: 5px 0;
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<!-- 使用 module 模式引入依赖 --> <!-- 使用 module 模式引入依赖 -->
<script type="module" src="./index.js"></script> <script type="module" src="./index.js"></script>
</head> </head>
<body> <body style="background-color: #fff;">
<div id="app"> <div id="app">
<div class="ai-video-editor" <div class="ai-video-editor"
:element-loading-text="loadingText" :element-loading-text="loadingText"
...@@ -94,6 +94,14 @@ ...@@ -94,6 +94,14 @@
<el-card shadow="hover"> <el-card shadow="hover">
<div slot="header" class="clearfix"> <div slot="header" class="clearfix">
<span>字幕生成结果</span> <span>字幕生成结果</span>
<el-button
v-if="subtitlesResult && subtitlesResult.subtitleJsonUrl && subtitlesResult.translatedJsonUrl"
size="mini"
type="primary"
style="float:right; margin-right:8px;"
@click="openSubtitleEditor">
编辑
</el-button>
<el-tag size="mini" type="success" style="float:right;">已完成</el-tag> <el-tag size="mini" type="success" style="float:right;">已完成</el-tag>
</div> </div>
<div> <div>
...@@ -140,6 +148,94 @@ ...@@ -140,6 +148,94 @@
</el-card> </el-card>
</div> </div>
</div> </div>
<!-- 全屏字幕编辑器 -->
<el-dialog
title="字幕编辑器"
:visible.sync="subtitleEditorVisible"
fullscreen
:close-on-click-modal="false"
:destroy-on-close="true"
@open="onSubtitleEditorOpen">
<div v-loading="subtitleEditorLoading">
<div style="margin-bottom:12px;">
<el-button type="primary" size="mini" @click="applyEditorChanges">保存修改</el-button>
<el-button type="danger" size="mini" @click="overwriteAndRegenerateLyrics">覆盖上传并重新生成歌词</el-button>
</div>
<div v-if="subtitleEditor && subtitleEditor.mergedList">
<el-timeline>
<el-timeline-item
v-for="item in subtitleEditor.mergedList"
:key="`tl-${item.index}`"
:timestamp="item.start"
placement="top"
>
<el-card shadow="never" body-style="padding:12px;">
<div style="display:flex; align-items:center; gap:8px; color:#909399; font-size:12px; margin-bottom:4px;">
<span>#{{ item.index }}</span>
<span>{{ item.start }} - {{ item.end }}</span>
</div>
<div style="display:flex; align-items:center; gap:8px; font-size:16px; margin-bottom:4px;">
<span style="display:block; line-height:1.05; margin:0; padding:0;">
{{ buildSentenceFromWords(item.sWordsRef) }}
</span>
</div>
<div style="display:flex; align-items:center; gap:8px; font-size:16px; margin-bottom:4px;">
<span style="display:block; line-height:1.05; margin:0; padding:0; margin-top:-2px; color:#909399;">
{{ buildSentenceFromTranslatedItems(item.tItemsRef) }}
</span>
</div>
<div style="display:flex; gap:12px; margin-top:8px; flex-wrap:wrap;">
<div style="flex:1; min-width:300px;">
<div style="font-weight:500; margin-bottom:4px;">原文分词</div>
<el-table :data="item.sWordsRef || []" size="mini" border>
<el-table-column label="start" width="90">
<template slot-scope="s">
{{ formatTimeNumeric(s.row.start) }}
</template>
</el-table-column>
<el-table-column label="end" width="90">
<template slot-scope="s">
{{ formatTimeNumeric(s.row.end) }}
</template>
</el-table-column>
<el-table-column label="word">
<template slot-scope="s">
<el-input v-model="s.row.word" size="mini"></el-input>
</template>
</el-table-column>
</el-table>
</div>
<div style="flex:1; min-width:300px;">
<div style="font-weight:500; margin-bottom:4px;">翻译</div>
<el-table :data="item.tItemsRef || []" size="mini" border>
<el-table-column label="start" width="90">
<template slot-scope="t">
{{ formatTimeNumeric(t.row.start) }}
</template>
</el-table-column>
<el-table-column label="end" width="90">
<template slot-scope="t">
{{ formatTimeNumeric(t.row.end) }}
</template>
</el-table-column>
<el-table-column label="text">
<template slot-scope="t">
<el-input v-model="t.row.text" size="mini"></el-input>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
<div v-else>
<el-empty description="暂无数据"></el-empty>
</div>
</div>
</el-dialog>
</div> </div>
</div> </div>
</body> </body>
......
...@@ -28,16 +28,13 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -28,16 +28,13 @@ document.addEventListener('DOMContentLoaded', () => {
window.courseware.getData(function (data) { window.courseware.getData(function (data) {
window.air.hideAirClassLoading(key, data); window.air.hideAirClassLoading(key, data);
// if (!data) { if (!data) {
// // 加载默认数据 // 加载默认数据
// $.getJSON('../play/defaultData.json', function (defaultData) { $.getJSON('../play/defaultData.json', function (defaultData) {
// callServerMethod('getGameList', {}).then(res => { initVueApp(defaultData);
// gameSetObj = res; })
// initVueApp(defaultData); return;
// }); }
// })
// return;
// }
//数据加载完,才算页面加载完成 //数据加载完,才算页面加载完成
initVueApp(data); initVueApp(data);
}, key); }, key);
...@@ -71,6 +68,15 @@ function initVueApp(savedData) { ...@@ -71,6 +68,15 @@ function initVueApp(savedData) {
vocalResult: null, vocalResult: null,
vocalPollTimer: null, vocalPollTimer: null,
vocalLoadingText: '', vocalLoadingText: '',
// 字幕编辑器
subtitleEditorVisible: false,
subtitleEditorLoading: false,
subtitleEditor: {
subtitleList: [],
translatedList: [],
mergedList: []
},
}, },
mounted() { mounted() {
// 如果有保存的数据,恢复状态 // 如果有保存的数据,恢复状态
...@@ -612,6 +618,550 @@ function initVueApp(savedData) { ...@@ -612,6 +618,550 @@ function initVueApp(savedData) {
} }
}, },
// 打开字幕编辑器
async openSubtitleEditor() {
if (!this.subtitlesResult || !this.subtitlesResult.subtitleJsonUrl || !this.subtitlesResult.translatedJsonUrl) {
this.$message.warning('暂无可编辑的字幕数据');
return;
}
this.subtitleEditorVisible = true;
},
// 弹窗打开时加载数据
async onSubtitleEditorOpen() {
try {
this.subtitleEditorLoading = true;
const subtitleUrl = (this.subtitlesResult.subtitleJsonUrl || '').replace('http://', 'https://');
const translatedUrl = (this.subtitlesResult.translatedJsonUrl || '').replace('http://', 'https://');
const [subtitleResp, translatedResp] = await Promise.all([
fetch(subtitleUrl, { mode: 'cors', cache: 'no-cache' }),
fetch(translatedUrl, { mode: 'cors', cache: 'no-cache' })
]);
const [subtitleJson, translatedJson] = await Promise.all([
subtitleResp.json(),
translatedResp.json()
]);
// 兼容数据根可能不是数组(深度搜索)
const subtitleList = this.extractArrayFromJson(subtitleJson, 'subtitle');
const translatedList = this.extractArrayFromJson(translatedJson, 'translated');
this.subtitleEditor.subtitleList = subtitleList || [];
this.subtitleEditor.translatedList = translatedList || [];
this.subtitleEditor.mergedList = this.buildMergedList(this.subtitleEditor.subtitleList, this.subtitleEditor.translatedList);
} catch (e) {
console.error(e);
this.$message.error('加载字幕/翻译JSON失败');
this.subtitleEditor.mergedList = [];
} finally {
this.subtitleEditorLoading = false;
}
},
// 从任意 JSON 中提取数组(优先常见字段,其次深度搜索)
extractArrayFromJson(root, type) {
if (!root) return [];
if (Array.isArray(root)) {
// 顶层是数组:
// - 若元素本身是数组 → 扁平化一次
// - 若元素是对象且包含 results 为数组 → 扁平化提取所有 results
// - 否则直接返回原数组
if (root.length === 0) return root;
if (Array.isArray(root[0])) {
return root.reduce((acc, cur) => acc.concat(cur), []);
}
if (root[0] && typeof root[0] === 'object' && Array.isArray(root[0].results)) {
return root.reduce((acc, group) => {
if (group && Array.isArray(group.results)) acc.push(...group.results);
return acc;
}, []);
}
return root;
}
// 常见候选键
const primaryKeys = [
'items','segments','subtitles','sentences','list','lines','utterances','words','cues','entries'
];
const secondaryKeys = ['data','result','results','payload','response'];
// 1) 直接命中主键
for (const k of primaryKeys) {
const v = root[k];
if (Array.isArray(v)) return v;
}
// 2) 次级容器下的主键
for (const c of secondaryKeys) {
const container = root[c];
if (container && typeof container === 'object') {
if (Array.isArray(container)) return container;
for (const k of primaryKeys) {
const v = container[k];
if (Array.isArray(v)) return v;
}
}
}
// 3) 对象的数值键(如 {"0":{...},"1":{...}})
const numKeys = Object.keys(root).filter(k => /^\d+$/.test(k)).sort((a,b)=>Number(a)-Number(b));
if (numKeys.length && typeof root[numKeys[0]] === 'object') {
return numKeys.map(k => root[k]);
}
// 4) 深度搜索第一个对象数组
const found = this.findFirstArrayDeep(root);
if (found) return found;
return [];
},
// 深度优先搜索第一个对象数组
findFirstArrayDeep(obj, depth = 0, maxDepth = 5) {
if (!obj || depth > maxDepth) return null;
if (Array.isArray(obj)) {
// 仅接受对象数组作为候选
if (obj.length === 0) return obj;
if (obj.some(x => typeof x === 'object')) return obj;
}
if (typeof obj === 'object') {
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const v = obj[key];
const res = this.findFirstArrayDeep(v, depth + 1, maxDepth);
if (res) return res;
}
}
return null;
},
// 构建时间轴合并数据
buildMergedList(subtitleList, translatedList) {
const sNorm = (subtitleList || []).map((s, idx) => {
const { start, end } = this.extractTimesFromItem(s, null);
const startSec = this.parseTimeToSeconds(start);
const endSec = this.parseTimeToSeconds(end);
return {
sIndex: idx,
startSec,
endSec,
raw: s,
text: s && (s.text != null ? s.text : (s.content != null ? s.content : '')),
word: s && (s.word != null ? s.word : (s.words != null ? s.words : null)),
sWordsRef: s ? (Array.isArray(s.words) ? s.words : (Array.isArray(s.word) ? s.word : null)) : null
};
}).filter(x => !isNaN(x.startSec) && !isNaN(x.endSec));
const tNorm = (translatedList || []).map((t, idx) => {
const { start, end } = this.extractTimesFromItem(null, t);
const startSec = this.parseTimeToSeconds(start);
const endSec = this.parseTimeToSeconds(end);
// 优先取顶层 text/src;若没有,则拼接 translatedItems[].text 作为备选显示
let displayText = null;
if (t) {
if (t.text != null) {
displayText = t.text;
} else if (t.src != null) {
displayText = t.src;
} else if (Array.isArray(t.translatedItems)) {
displayText = t.translatedItems.map(it => (it && it.text) ? it.text : '').join('');
} else {
displayText = '';
}
}
return {
tIndex: idx,
startSec,
endSec,
raw: t,
text: displayText,
translated: t && (t.translated != null ? t.translated : (t.tgt != null ? t.tgt : '')),
translatedItemsString: this.safeStringify(t && t.translatedItems),
tItemsRef: t ? (Array.isArray(t.translatedItems) ? t.translatedItems : null) : null
};
});
// 按开始时间排序,确保时间轴有序
sNorm.sort((a, b) => a.startSec - b.startSec);
tNorm.sort((a, b) => {
const aa = isNaN(a.startSec) ? Number.MAX_SAFE_INTEGER : a.startSec;
const bb = isNaN(b.startSec) ? Number.MAX_SAFE_INTEGER : b.startSec;
return aa - bb;
});
// 为每个字幕段寻找时间上最匹配的翻译段(最大重叠;若无重叠则选最近起始)
const merged = [];
const base = sNorm.length > 0 ? 's' : (tNorm.length > 0 ? 't' : null);
if (!base) return [];
if (base === 's') {
for (const s of sNorm) {
let best = null;
let bestScore = -1;
for (const t of tNorm) {
const score = this.intervalOverlapScore(s.startSec, s.endSec, t.startSec, t.endSec);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
// 若没有任何有效时间(例如翻译无时间),bestScore 可能为 -1,则退化为按最接近 startSec 的项
if (bestScore <= 0) {
let nearest = null;
let nearestDist = Number.MAX_SAFE_INTEGER;
for (const t of tNorm) {
if (isNaN(t.startSec)) continue;
const dist = Math.abs(t.startSec - s.startSec);
if (dist < nearestDist) {
nearestDist = dist;
nearest = t;
}
}
if (nearest) best = nearest;
}
const tMatch = best || {};
merged.push({
index: s.sIndex + 1,
sIndex: s.sIndex,
tIndex: tMatch.tIndex != null ? tMatch.tIndex : null,
start: this.formatTime(s.startSec),
end: this.formatTime(s.endSec),
subtitleText: s.text || '',
subtitleWordString: this.safeStringify(s.word),
translatedText: (tMatch.text || ''),
translatedTranslated: (tMatch.translated || ''),
translatedItemsString: this.safeStringify(tMatch && tMatch.raw && tMatch.raw.translatedItems),
sWordsRef: s.sWordsRef || [],
tItemsRef: tMatch && tMatch.tItemsRef ? tMatch.tItemsRef : []
});
}
} else {
// 以翻译为基准(字幕为空时也能显示时间轴)
for (const t of tNorm) {
merged.push({
index: t.tIndex + 1,
sIndex: null,
tIndex: t.tIndex,
start: this.formatTime(t.startSec),
end: this.formatTime(t.endSec),
subtitleText: '',
subtitleWordString: '',
translatedText: (t.text || ''),
translatedTranslated: (t.translated || ''),
translatedItemsString: this.safeStringify(t && t.raw && t.raw.translatedItems),
sWordsRef: [],
tItemsRef: t.tItemsRef || []
});
}
}
return merged;
},
// 从条目中尽可能提取开始/结束时间,兼容多字段与单位
extractTimesFromItem(s, t) {
const candidatesStart = [
'start','startTime','from','begin','offset','st','tsStart',
'start_ms','startMs','startTimeMs','segStart'
];
const candidatesEnd = [
'end','endTime','to','finish','et','tsEnd',
'end_ms','endMs','endTimeMs','segEnd'
];
let start = null;
let end = null;
for (const k of candidatesStart) {
if (s && s[k] != null) { start = s[k]; break; }
}
for (const k of candidatesEnd) {
if (s && s[k] != null) { end = s[k]; break; }
}
// 若字幕条目没有,尝试从翻译条目补齐
if (start == null && t) {
for (const k of candidatesStart) { if (t[k] != null) { start = t[k]; break; } }
}
if (end == null && t) {
for (const k of candidatesEnd) { if (t[k] != null) { end = t[k]; break; } }
}
// 若仍缺 end,尝试 duration
const duration = (s && (s.duration ?? s.dur ?? s.len)) ?? (t && (t.duration ?? t.dur ?? t.len));
if ((end == null || end === '') && (start != null) && (duration != null)) {
const sSec = this.parseTimeToSeconds(start);
const dSec = this.parseTimeToSeconds(duration);
if (!isNaN(sSec) && !isNaN(dSec)) {
end = sSec + dSec;
}
}
return { start, end };
},
// 区间重叠评分:交集时长 / 并集时长(无效则返回 -1)
intervalOverlapScore(aStart, aEnd, bStart, bEnd) {
if (isNaN(aStart) || isNaN(aEnd) || isNaN(bStart) || isNaN(bEnd)) return -1;
const inter = Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart));
const uni = Math.max(aEnd, bEnd) - Math.min(aStart, bStart);
if (uni <= 0) return -1;
return inter / uni;
},
// 应用修改回原始数组
applyEditorChanges() {
const merged = this.subtitleEditor.mergedList || [];
const sList = this.subtitleEditor.subtitleList || [];
const tList = this.subtitleEditor.translatedList || [];
const len = merged.length;
let invalidJsonCount = 0;
for (let i = 0; i < len; i++) {
const m = merged[i];
if (!m) continue;
// 写回字幕数组
const sIdx = (m.sIndex != null ? m.sIndex : i);
if (!sList[sIdx]) sList[sIdx] = {};
// 保持分词引用写回
if (Array.isArray(m.sWordsRef)) {
// 确保使用 words 字段存储
sList[sIdx].words = m.sWordsRef;
// 根据分词动态刷新句子 text
sList[sIdx].text = this.buildSentenceFromWords(m.sWordsRef);
}
// 写回翻译数组
let tIdx = (m.tIndex != null ? m.tIndex : sIdx);
if (!tList[tIdx]) {
// 如果对应索引不存在,则在该索引位创建(稀疏也可)
tList[tIdx] = {};
}
if (Array.isArray(m.tItemsRef)) {
tList[tIdx].translatedItems = m.tItemsRef;
// 根据分段动态刷新整句 translated
tList[tIdx].translated = this.buildSentenceFromTranslatedItems(m.tItemsRef);
}
}
this.$message.success('修改已应用到内存');
},
// 导出已编辑 JSON
downloadEdited(type) {
if (type === 'subtitle') {
this.applyEditorChanges();
this.createAndDownloadJson('subtitle_edited.json', this.subtitleEditor.subtitleList || []);
} else if (type === 'translated') {
this.applyEditorChanges();
this.createAndDownloadJson('translated_edited.json', this.subtitleEditor.translatedList || []);
}
},
// 覆盖上传并重新生成歌词(确认后执行)
async overwriteAndRegenerateLyrics() {
try {
await this.$confirm(
'本操作会覆盖文件并重新生成歌词文件,耗时3到5分钟,并且本次操作不可逆,确认已全部修改好并重新生成歌词吗',
'确认覆盖并重新生成',
{ type: 'warning', confirmButtonText: '确认', cancelButtonText: '取消' }
);
} catch (e) {
return; // 用户取消
}
// 应用到内存
this.applyEditorChanges();
// 基于当前编辑数据生成 JSON 字符串
const subtitleData = this.subtitleEditor.subtitleList || [];
const translatedData = this.subtitleEditor.translatedList || [];
const subtitleJson = JSON.stringify(subtitleData, null, 2);
const translatedJson = JSON.stringify(translatedData, null, 2);
const loading = this.$loading({ lock: true, text: '正在覆盖上传歌词文件...', spinner: 'el-icon-loading', background: 'rgba(0,0,0,0.7)' });
try {
// 获取 OSS 凭证并构建 client
const ossSignature = await callServerMethod('getOSSSignature');
const ossConfig = ossSignature && ossSignature.data;
if (!ossConfig) {
this.$message.error('获取OSS凭证失败');
loading.close();
return;
}
const client = new OSS({
region: ossConfig.region,
accessKeyId: ossConfig.accessKeyId,
accessKeySecret: ossConfig.accessKeySecret,
stsToken: ossConfig.securityToken,
bucket: ossConfig.bucket,
cors: true,
secure: true,
silent: true
});
// 从现有URL解析对象名(避免覆盖,追加随机后缀)
const subtitleKeyBase = this.parseKeyFromUrl((this.subtitlesResult && this.subtitlesResult.subtitleJsonUrl) || '');
const translatedKeyBase = this.parseKeyFromUrl((this.subtitlesResult && this.subtitlesResult.translatedJsonUrl) || '');
if (!subtitleKeyBase || !translatedKeyBase) {
this.$message.error('未找到原始歌词文件URL,无法覆盖上传');
loading.close();
return;
}
const rand = this.randomId(4);
const subtitleKey = this.addSuffixToKey(subtitleKeyBase, `_${rand}`);
const translatedKey = this.addSuffixToKey(translatedKeyBase, `_${rand}`);
// 上传覆盖
const sBlob = new Blob([subtitleJson], { type: 'application/json;charset=utf-8' });
const tBlob = new Blob([translatedJson], { type: 'application/json;charset=utf-8' });
await Promise.all([
client.put(subtitleKey, sBlob),
client.put(translatedKey, tBlob),
]);
// 生成新 URL 并更新 savedData
const domain = (ossConfig.domain || '').replace('http://', 'https://');
const newSubtitleUrl = `${domain}${subtitleKey}`;
const newTranslatedUrl = `${domain}${translatedKey}`;
if (!this.subtitlesResult) this.subtitlesResult = {};
this.subtitlesResult.subtitleJsonUrl = newSubtitleUrl;
this.subtitlesResult.translatedJsonUrl = newTranslatedUrl;
this.saveData();
this.$message.success('新歌词文件已上传并更新链接(避免CDN缓存)');
// 可选刷新 CDN 或在URL后追加时间戳避免缓存
// TODO(lyrics): 补全覆盖后触发“重新生成歌词文件”的后端调用逻辑
// this.regenerateLyricsAfterUpload();
} catch (e) {
console.error(e);
this.$message.error('覆盖上传失败');
} finally {
loading.close();
}
},
// 从 URL 提取对象名(最后一个路径段)
parseKeyFromUrl(url) {
try {
const u = new URL(url);
const path = u.pathname || '';
const segs = path.split('/').filter(Boolean);
return segs.length ? segs[segs.length - 1] : '';
} catch (e) {
// 非法URL,尝试手动截取
if (!url) return '';
const q = url.split('?')[0];
const idx = q.lastIndexOf('/');
if (idx >= 0) return q.substring(idx + 1);
return q;
}
},
// 在文件名扩展名前追加后缀,例如 a.json -> a_suffix.json
addSuffixToKey(key, suffix) {
const dot = key.lastIndexOf('.');
let name = key;
let ext = '';
if (dot > 0) {
name = key.substring(0, dot);
ext = key.substring(dot);
}
// 若已存在一次 4 位随机后缀(如 _abcd),先移除再追加新的,避免多次叠加
name = name.replace(/_[a-zA-Z0-9]{4}$/, '');
return `${name}${suffix}${ext}`;
},
// 生成随机字符串(base36)
randomId(len = 4) {
let out = '';
while (out.length < len) {
out += Math.random().toString(36).slice(2);
}
return out.slice(0, len);
},
// 工具:创建并下载JSON文件
createAndDownloadJson(filename, data) {
try {
const jsonStr = JSON.stringify(data, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
this.$message.error('导出失败');
}
},
// 工具:数字/字符串时间转可读 mm:ss.mmm
formatTimeNumeric(value) {
const sec = this.parseTimeToSeconds(value);
if (!isNaN(sec)) return this.formatTime(sec);
// 允许逗号毫秒样式直接替换显示
if (typeof value === 'string') {
const normalized = value.replace(',', '.');
if (/\d{1,2}:\d{2}(:\d{2})?\.\d{1,3}/.test(normalized)) return normalized;
}
return value || '';
},
// 将各种时间输入转换为展示字符串
toDisplayTime(value) {
const sec = this.parseTimeToSeconds(value);
if (!isNaN(sec)) return this.formatTime(sec);
if (typeof value === 'string' && value) return value.replace(',', '.');
return '';
},
// 解析多种时间格式为秒数(支持 ms、逗号毫秒、HH:MM:SS.mmm)
parseTimeToSeconds(value) {
if (value == null || value === '') return NaN;
if (typeof value === 'number') {
// 大于 1000 可能是毫秒
if (value > 1000 && Number.isInteger(value)) return value / 1000;
return value;
}
if (typeof value === 'string') {
const v = value.trim();
if (!v) return NaN;
// 替换逗号为点
const norm = v.replace(',', '.');
// 含有冒号:HH:MM:SS.mmm 或 MM:SS.mmm
if (norm.includes(':')) {
const parts = norm.split(':');
// 支持 1~3 段
const last = parseFloat(parts.pop());
if (isNaN(last)) return NaN;
let seconds = last;
if (parts.length) {
const secPart = parseFloat(parts.pop());
if (!isNaN(secPart)) seconds += secPart * 60;
}
if (parts.length) {
const hourPart = parseFloat(parts.pop());
if (!isNaN(hourPart)) seconds += hourPart * 3600;
}
return seconds;
}
// 纯数字字符串,可能是秒/毫秒
const num = parseFloat(norm);
if (!isNaN(num)) {
if (num > 1000 && Number.isInteger(num)) return num / 1000;
return num;
}
}
return NaN;
},
// 工具:安全 JSON.stringify
safeStringify(obj) {
try {
if (obj == null) return '';
return JSON.stringify(obj);
} catch (e) {
return '';
}
},
// 工具:从分词构建句子
buildSentenceFromWords(words) {
if (!Array.isArray(words)) return '';
const raw = words
.map(w => (w && typeof w.word === 'string') ? w.word : '')
.filter(s => s && s.length > 0)
.join(' ');
// 去掉标点前的多余空格: , . ! ? ; : ) ] } 等
return raw.replace(/\s+([,.;:!?)}\]])/g, '$1');
},
// 工具:从翻译分段构建整句
buildSentenceFromTranslatedItems(items) {
if (!Array.isArray(items)) return '';
// 中文翻译保持不插入空格
return items.map(it => (it && typeof it.text === 'string') ? it.text : '').join('');
},
// 数据持久化 // 数据持久化
saveData() { saveData() {
const data = { const data = {
......
{ {
"videoUrl": "https://staging-teach.cdn.ireadabc.com/bcb8b751b98371ea71956a1d1a447956.mp4", "videoUrl": "https://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2.mp4",
"markers": [
{
"id": 1756951682854,
"time": 4.639372,
"text": "标记点 1",
"stem": "",
"description": "",
"title": "",
"questionType": "",
"options": null,
"matchingGroups": null,
"splitScreen": "one",
"gameData": {
"id": 1783460,
"cover": "http://staging-teach.cdn.ireadabc.com/89d6c301-94e2-4145-a795-7bc9ab409799.png",
"name": null,
"template_name": "shoot_bubble",
"last_version": 12,
"play_url": "play/index.html",
"form_url": "form/index.html?type=form",
"config": {
"countdown": null,
"correctJumpTo": null,
"wrongJumpTo": null,
"isRegionMarker": false
}
}
}
],
"playbackRate": 1, "playbackRate": 1,
"isOpenCamera": true "subtitlesResult": {
"assUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2.ass",
"srtUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2.srt",
"vttUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2.vtt",
"audioUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_audio.wav",
"videoUrl": "https://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2.mp4",
"chineseAssUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_cn.ass",
"chineseSrtUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_cn.srt",
"chineseVttUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_cn.vtt",
"outputVideoUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_with_bilingual_subtitles.mp4",
"bilingualAssUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_bilingual.ass",
"subtitleJsonUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_subtitle.json",
"translatedJsonUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_translated.json"
},
"vocalResult": {
"noAudioUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_no_audio.mp4",
"onlyVocalUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_only_vocal.mp4",
"removeVocalUrl": "http://teach.cdn.ireadabc.com/b3be955c80a0b969a9e3166ad9f36ae2_remove_vocal.mp4"
}
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment