diff --git a/docs/TODO.md b/docs/TODO.md
index 971a642..9fcc06a 100644
--- a/docs/TODO.md
+++ b/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 的字幕引擎
- [ ] 减小软件不必要的体积
## 遥远的未来
diff --git a/src/renderer/src/components/CaptionLog.vue b/src/renderer/src/components/CaptionLog.vue
index 4f549d4..c435fd7 100644
--- a/src/renderer/src/components/CaptionLog.vue
+++ b/src/renderer/src/components/CaptionLog.vue
@@ -4,46 +4,102 @@
{{ $t('log.title') }}
- {{ $t('log.export') }}
-
-
-
-
-
-
- {{ $t('log.copy') }}
+
+
+
+
+
+
{{ $t('log.hour') }}
+
+
:
+
+
+
+
{{ $t('log.min') }}
+
+
:
+
+
+
+
{{ $t('log.sec') }}
+
+
.
+
+
+ {{ $t('log.changeTime') }}
+
+
+
+
+
+ {{ $t('log.export') }}
+
+
+
+
+
+
+ {{ $t('log.copy') }}
-
{{ $t('log.clear') }}
+
@@ -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(0)
+const baseMM = ref(0)
+const baseSS = ref(0)
+const baseMS = ref(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;
diff --git a/src/renderer/src/components/CaptionStyle.vue b/src/renderer/src/components/CaptionStyle.vue
index 18af098..3a0a74c 100644
--- a/src/renderer/src/components/CaptionStyle.vue
+++ b/src/renderer/src/components/CaptionStyle.vue
@@ -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 {
diff --git a/src/renderer/src/components/EngineStatus.vue b/src/renderer/src/components/EngineStatus.vue
index 17fa9aa..fbe31ff 100644
--- a/src/renderer/src/components/EngineStatus.vue
+++ b/src/renderer/src/components/EngineStatus.vue
@@ -106,7 +106,6 @@ function openCaptionWindow() {
}
function startEngine() {
- console.log(`@@${engineControl.modelPath}##`)
if(engineControl.engine === 'vosk' && engineControl.modelPath.trim() === '') {
engineControl.emptyModelPathErr()
return
diff --git a/src/renderer/src/i18n/lang/en.ts b/src/renderer/src/i18n/lang/en.ts
index 04b8154..1fe701a 100644
--- a/src/renderer/src/i18n/lang/en.ts
+++ b/src/renderer/src/i18n/lang/en.ts
@@ -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"
}
}
diff --git a/src/renderer/src/i18n/lang/ja.ts b/src/renderer/src/i18n/lang/ja.ts
index af36f94..3e53b99 100644
--- a/src/renderer/src/i18n/lang/ja.ts
+++ b/src/renderer/src/i18n/lang/ja.ts
@@ -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": "字幕ログをクリア"
}
}
diff --git a/src/renderer/src/i18n/lang/zh.ts b/src/renderer/src/i18n/lang/zh.ts
index d24aa9e..88c4201 100644
--- a/src/renderer/src/i18n/lang/zh.ts
+++ b/src/renderer/src/i18n/lang/zh.ts
@@ -115,8 +115,16 @@ export default {
},
log: {
"title": "字幕记录",
+ "changeTime": "修改字幕时间",
+ "baseTime": "首条字幕起始时间",
+ "hour": "时",
+ "min": "分",
+ "sec": "秒",
+ "ms": "毫秒",
"export": "导出字幕记录",
"copy": "复制到剪贴板",
+ "exportOptions": "导出选项",
+ "exportFormat": "导出格式",
"copyOptions": "复制选项",
"addIndex": "添加序号",
"copyTime": "复制时间",
diff --git a/src/renderer/src/utils/timeCalc.ts b/src/renderer/src/utils/timeCalc.ts
new file mode 100644
index 0000000..ea9cf8f
--- /dev/null
+++ b/src/renderer/src/utils/timeCalc.ts
@@ -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));
+}
diff --git a/src/renderer/src/views/CaptionPage.vue b/src/renderer/src/views/CaptionPage.vue
index 5dc3bc9..69da4ae 100644
--- a/src/renderer/src/views/CaptionPage.vue
+++ b/src/renderer/src/views/CaptionPage.vue
@@ -1,24 +1,11 @@
@@ -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;
+}