feat(caption): 调整字幕窗口、添加字幕时间轴修改 (#8)

- 新增修改字幕时间功能
- 添加导出字幕记录类型,支持 srt 和 json 格式
- 调整字幕窗口右上角图标为竖向排布
This commit is contained in:
himeditator
2025-07-14 20:07:22 +08:00
parent 665c47d24f
commit a0a0a2e66d
9 changed files with 282 additions and 82 deletions

View File

@@ -10,12 +10,19 @@
- [x] 适配 macOS 平台 *2025/07/08*
- [x] 添加字幕文字描边 *2025/07/09*
- [x] 添加基于 Vosk 的字幕引擎 *2025/07/09*
- [x] 适配 Linux 平台 *2025/07/13*
- [x] 字幕窗口右上角图标改为竖向排布 *2025/07/14*
- [x] 可以调整字幕时间轴 *2025/07/14*
- [x] 可以导出 srt 格式的字幕记录 *2025/07/14*
## 待完成
- [ ] 可以获取字幕引擎的系统资源消耗情况
## 后续计划
- [ ] 添加 Ollama 模型用于本地字幕引擎的翻译
- [ ] 添加本地字幕引擎
- [ ] 验证 / 添加基于 FunASR 的字幕引擎
- [ ] 验证 / 添加基于 FunASR 的字幕引擎
- [ ] 减小软件不必要的体积
## 遥远的未来

View File

@@ -4,46 +4,102 @@
<a-app class="caption-title">
<span style="margin-right: 30px;">{{ $t('log.title') }}</span>
</a-app>
<a-button
type="primary"
style="margin-right: 20px;"
@click="exportCaptions"
:disabled="captionData.length === 0"
>{{ $t('log.export') }}</a-button>
<a-popover :title="$t('log.copyOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.addIndex') }}</span>
<a-switch v-model:checked="showIndex" />
<span class="input-label">{{ $t('log.copyTime') }}</span>
<a-switch v-model:checked="copyTime" />
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.copyContent') }}</span>
<a-radio-group v-model:value="copyOption">
<a-radio-button value="both">{{ $t('log.both') }}</a-radio-button>
<a-radio-button value="source">{{ $t('log.source') }}</a-radio-button>
<a-radio-button value="target">{{ $t('log.translation') }}</a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="copyCaptions"
:disabled="captionData.length === 0"
>{{ $t('log.copy') }}</a-button>
<a-popover :title="$t('log.baseTime')">
<template #content>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0"
v-model:value="baseHH"
></a-input>
<span class="base-time-label">{{ $t('log.hour') }}</span>
</div>
</div><span style="margin: 0 4px;">:</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="59"
v-model:value="baseMM"
></a-input>
<span class="base-time-label">{{ $t('log.min') }}</span>
</div>
</div><span style="margin: 0 4px;">:</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="59"
v-model:value="baseSS"
></a-input>
<span class="base-time-label">{{ $t('log.sec') }}</span>
</div>
</div><span style="margin: 0 4px;">.</span>
<div class="base-time">
<div class="base-time-container">
<a-input
type="number" min="0" max="999"
v-model:value="baseMS"
></a-input>
<span class="base-time-label">{{ $t('log.ms') }}</span>
</div>
</div>
</template>
<a-button
type="primary"
style="margin-right: 20px;"
@click="changeBaseTime"
:disabled="captionData.length === 0"
>{{ $t('log.changeTime') }}</a-button>
</a-popover>
<a-popover :title="$t('log.exportOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.exportFormat') }}</span>
<a-radio-group v-model:value="exportFormat">
<a-radio-button value="srt">.srt</a-radio-button>
<a-radio-button value="json">.json</a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="exportCaptions"
:disabled="captionData.length === 0"
>{{ $t('log.export') }}</a-button>
</a-popover>
<a-popover :title="$t('log.copyOptions')">
<template #content>
<div class="input-item">
<span class="input-label">{{ $t('log.addIndex') }}</span>
<a-switch v-model:checked="showIndex" />
<span class="input-label">{{ $t('log.copyTime') }}</span>
<a-switch v-model:checked="copyTime" />
</div>
<div class="input-item">
<span class="input-label">{{ $t('log.copyContent') }}</span>
<a-radio-group v-model:value="copyOption">
<a-radio-button value="both">{{ $t('log.both') }}</a-radio-button>
<a-radio-button value="source">{{ $t('log.source') }}</a-radio-button>
<a-radio-button value="target">{{ $t('log.translation') }}</a-radio-button>
</a-radio-group>
</div>
</template>
<a-button
style="margin-right: 20px;"
@click="copyCaptions"
:disabled="captionData.length === 0"
>{{ $t('log.copy') }}</a-button>
</a-popover>
<a-button
danger
@click="clearCaptions"
>{{ $t('log.clear') }}</a-button>
</div>
<a-table
:columns="columns"
:data-source="captionData"
v-model:pagination="pagination"
style="margin-top: 10px;"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'index'">
@@ -72,15 +128,23 @@ import { storeToRefs } from 'pinia'
import { useCaptionLogStore } from '@renderer/stores/captionLog'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import * as tc from '../utils/timeCalc'
const { t } = useI18n()
const captionLog = useCaptionLogStore()
const { captionData } = storeToRefs(captionLog)
const exportFormat = ref('srt')
const showIndex = ref(true)
const copyTime = ref(true)
const copyOption = ref('both')
const baseHH = ref<number>(0)
const baseMM = ref<number>(0)
const baseSS = ref<number>(0)
const baseMS = ref<number>(0)
const pagination = ref({
current: 1,
pageSize: 10,
@@ -117,20 +181,58 @@ const columns = [
},
]
function changeBaseTime() {
if(baseHH.value < 0) baseHH.value = 0
if(baseMM.value < 0) baseMM.value = 0
if(baseMM.value > 59) baseMM.value = 59
if(baseSS.value < 0) baseSS.value = 0
if(baseSS.value > 59) baseSS.value = 59
if(baseMS.value < 0) baseMS.value = 0
if(baseMS.value > 999) baseMS.value = 999
const newBase: tc.Time = {
hh: Number(baseHH.value),
mm: Number(baseMM.value),
ss: Number(baseSS.value),
ms: Number(baseMS.value)
}
const oldBase = tc.getTimeFromStr(captionData.value[0].time_s)
const deltaMs = tc.getMsFromTime(newBase) - tc.getMsFromTime(oldBase)
for(let i = 0; i < captionData.value.length; i++){
captionData.value[i].time_s =
tc.getNewTimeStr(captionData.value[i].time_s, deltaMs)
captionData.value[i].time_t =
tc.getNewTimeStr(captionData.value[i].time_t, deltaMs)
}
}
function exportCaptions() {
const jsonData = JSON.stringify(captionData.value, null, 2)
const blob = new Blob([jsonData], { type: 'application/json' })
const exportData = getExportData()
const blob = new Blob([exportData], {
type: exportFormat.value === 'json' ? 'application/json' : 'text/plain'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
a.download = `captions-${timestamp}.json`
a.download = `captions-${timestamp}.${exportFormat.value}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function getExportData() {
if(exportFormat.value === 'json') return JSON.stringify(captionData.value, null, 2)
let content = ''
for(let i = 0; i < captionData.value.length; i++){
const item = captionData.value[i]
content += `${i+1}\n`
content += `${item.time_s} --> ${item.time_t}\n`.replace(/\./g, ',')
content += `${item.text}\n${item.translation}\n\n`
}
return content
}
function copyCaptions() {
let content = ''
for(let i = 0; i < captionData.value.length; i++){
@@ -166,6 +268,23 @@ function clearCaptions() {
margin-bottom: 10px;
}
.base-time {
width: 64px;
display: inline-block;
}
.base-time-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.base-time-label {
font-size: 12px;
color: var(--tag-color);
}
.time-cell {
display: flex;
flex-direction: column;

View File

@@ -335,13 +335,12 @@ watch(changeSignal, (val) => {
}
.preview-container {
line-height: 2em;
width: 60%;
text-align: center;
position: absolute;
padding: 20px;
padding: 10px;
border-radius: 10px;
left: 50%;
left: 64%;
transform: translateX(-50%);
bottom: 20px;
}
@@ -349,7 +348,7 @@ watch(changeSignal, (val) => {
.preview-container p {
text-align: center;
margin: 0;
line-height: 1.5em;
line-height: 1.6em;
}
.left-ellipsis {

View File

@@ -106,7 +106,6 @@ function openCaptionWindow() {
}
function startEngine() {
console.log(`@@${engineControl.modelPath}##`)
if(engineControl.engine === 'vosk' && engineControl.modelPath.trim() === '') {
engineControl.emptyModelPathErr()
return

View File

@@ -115,7 +115,16 @@ export default {
},
log: {
"title": "Caption Log",
"changeTime": "Modify Caption Time",
"baseTime": "First Caption Start Time",
"hour": "Hour",
"min": "Minute",
"sec": "Second",
"ms": "Millisecond",
"export": "Export Caption Log",
"copy": "Copy to Clipboard",
"exportOptions": "Export Options",
"exportFormat": "Format",
"copyOptions": "Copy Options",
"addIndex": "Add Index",
"copyTime": "Copy Time",
@@ -124,7 +133,6 @@ export default {
"source": "Original Only",
"translation": "Translation Only",
"copySuccess": "Subtitle copied to clipboard",
"export": "Export Caption Log",
"clear": "Clear Caption Log"
}
}

View File

@@ -115,7 +115,16 @@ export default {
},
log: {
"title": "字幕ログ",
"changeTime": "字幕時間を変更",
"baseTime": "最初の字幕開始時間",
"hour": "時",
"min": "分",
"sec": "秒",
"ms": "ミリ秒",
"export": "エクスポート",
"copy": "クリップボードにコピー",
"exportOptions": "エクスポートオプション",
"exportFormat": "形式",
"copyOptions": "コピー設定",
"addIndex": "順序番号",
"copyTime": "時間",
@@ -124,7 +133,6 @@ export default {
"source": "原文のみ",
"translation": "翻訳のみ",
"copySuccess": "字幕がクリップボードにコピーされました",
"export": "エクスポート",
"clear": "字幕ログをクリア"
}
}

View File

@@ -115,8 +115,16 @@ export default {
},
log: {
"title": "字幕记录",
"changeTime": "修改字幕时间",
"baseTime": "首条字幕起始时间",
"hour": "时",
"min": "分",
"sec": "秒",
"ms": "毫秒",
"export": "导出字幕记录",
"copy": "复制到剪贴板",
"exportOptions": "导出选项",
"exportFormat": "导出格式",
"copyOptions": "复制选项",
"addIndex": "添加序号",
"copyTime": "复制时间",

View File

@@ -0,0 +1,42 @@
export interface Time {
hh: number;
mm: number;
ss: number;
ms: number;
}
export function getTimeFromStr(time: string): Time {
const arr = time.split(":");
const hh = parseInt(arr[0]);
const mm = parseInt(arr[1]);
const ss = parseInt(arr[2].split(".")[0]);
const ms = parseInt(arr[2].split(".")[1]);
return { hh, mm, ss, ms };
}
export function getStrFromTime(time: Time): string {
return `${time.hh}:${time.mm}:${time.ss}.${time.ms}`;
}
export function getMsFromTime(time: Time): number {
return (
time.hh * 3600000 +
time.mm * 60000 +
time.ss * 1000 +
time.ms
);
}
export function getTimeFromMs(milliseconds: number): Time {
const hh = Math.floor(milliseconds / 3600000);
const mm = Math.floor((milliseconds % 3600000) / 60000);
const ss = Math.floor((milliseconds % 60000) / 1000);
const ms = milliseconds % 1000;
return { hh, mm, ss, ms };
}
export function getNewTimeStr(timeStr: string, Ms: number): string {
const timeMs = getMsFromTime(getTimeFromStr(timeStr));
const newTimeMs = timeMs + Ms;
return getStrFromTime(getTimeFromMs(newTimeMs));
}

View File

@@ -1,24 +1,11 @@
<template>
<div
class="caption-page"
ref="caption"
:style="{
backgroundColor: captionStyle.backgroundRGBA
}"
class="caption-page"
ref="caption"
:style="{
backgroundColor: captionStyle.backgroundRGBA
}"
>
<div class="title-bar" :style="{color: captionStyle.fontColor}">
<div class="drag-area">&nbsp;</div>
<div class="option-item" @click="pinCaptionWindow">
<PushpinFilled v-if="pinned" />
<PushpinOutlined v-else />
</div>
<div class="option-item" @click="openControlWindow">
<SettingOutlined />
</div>
<div class="option-item" @click="closeCaptionWindow">
<CloseOutlined />
</div>
</div>
<div
class="caption-container"
:style="{
@@ -46,6 +33,20 @@
<span v-else>{{ $t('example.translation') }}</span>
</p>
</div>
<div class="title-bar" :style="{color: captionStyle.fontColor}">
<div class="option-item" @click="closeCaptionWindow">
<CloseOutlined />
</div>
<div class="option-item" @click="openControlWindow">
<SettingOutlined />
</div>
<div class="option-item" @click="pinCaptionWindow">
<PushpinFilled v-if="pinned" />
<PushpinOutlined v-else />
</div>
<div class="drag-area"></div>
</div>
</div>
</template>
@@ -97,38 +98,21 @@ function closeCaptionWindow() {
border-radius: 8px;
box-sizing: border-box;
border: 1px solid #3333;
}
.title-bar {
display: flex;
align-items: center;
}
.drag-area {
padding: 5px;
flex-grow: 1;
-webkit-app-region: drag;
}
.option-item {
display: inline-block;
padding: 5px 10px;
cursor: pointer;
}
.option-item:hover {
background-color: #2221;
}
.caption-container {
display: inline-block;
width: calc(100% - 32px);
-webkit-app-region: drag;
padding-top: 10px;
padding-bottom: 10px;
}
.caption-container p {
text-align: center;
margin: 0;
line-height: 1.5em;
padding: 0 10px 10px 10px;
line-height: 1.6em;
}
.left-ellipsis {
@@ -142,4 +126,30 @@ function closeCaptionWindow() {
direction: ltr;
display: inline-block;
}
.title-bar {
width: 32px;
display: flex;
flex-direction: column;
vertical-align: top;
}
.option-item {
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.option-item:hover {
background-color: #2221;
}
.drag-area {
display: inline-flex;
flex-grow: 1;
-webkit-app-region: drag;
}
</style>