mirror of
https://github.com/HiMeditator/auto-caption.git
synced 2026-02-04 04:14:42 +08:00
feat(caption): 调整字幕窗口、添加字幕时间轴修改 (#8)
- 新增修改字幕时间功能 - 添加导出字幕记录类型,支持 srt 和 json 格式 - 调整字幕窗口右上角图标为竖向排布
This commit is contained in:
11
docs/TODO.md
11
docs/TODO.md
@@ -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 的字幕引擎
|
||||
- [ ] 减小软件不必要的体积
|
||||
|
||||
## 遥远的未来
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -106,7 +106,6 @@ function openCaptionWindow() {
|
||||
}
|
||||
|
||||
function startEngine() {
|
||||
console.log(`@@${engineControl.modelPath}##`)
|
||||
if(engineControl.engine === 'vosk' && engineControl.modelPath.trim() === '') {
|
||||
engineControl.emptyModelPathErr()
|
||||
return
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "字幕ログをクリア"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +115,16 @@ export default {
|
||||
},
|
||||
log: {
|
||||
"title": "字幕记录",
|
||||
"changeTime": "修改字幕时间",
|
||||
"baseTime": "首条字幕起始时间",
|
||||
"hour": "时",
|
||||
"min": "分",
|
||||
"sec": "秒",
|
||||
"ms": "毫秒",
|
||||
"export": "导出字幕记录",
|
||||
"copy": "复制到剪贴板",
|
||||
"exportOptions": "导出选项",
|
||||
"exportFormat": "导出格式",
|
||||
"copyOptions": "复制选项",
|
||||
"addIndex": "添加序号",
|
||||
"copyTime": "复制时间",
|
||||
|
||||
42
src/renderer/src/utils/timeCalc.ts
Normal file
42
src/renderer/src/utils/timeCalc.ts
Normal 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));
|
||||
}
|
||||
@@ -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"> </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>
|
||||
|
||||
Reference in New Issue
Block a user