Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
A
ai_fundub
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
template admin
ai_fundub
Commits
18a303ab
Commit
18a303ab
authored
Feb 09, 2026
by
liujiangnan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 字幕生成
parent
bcbe92bd
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
119 additions
and
55 deletions
+119
-55
index.html
form/index.html
+2
-7
index.js
form/index.js
+117
-48
No files found.
form/index.html
View file @
18a303ab
...
...
@@ -221,18 +221,13 @@
</el-page-header>
</span>
<div
v-loading=
"subtitleEditorLoading || isAnyTaskRunning()"
:element-loading-text=
"isAnyTaskRunning() ? '任务进行中,编辑已锁定' : ''"
v-loading=
"subtitleEditorLoading"
element-loading-spinner=
"el-icon-loading"
element-loading-background=
"rgba(255, 255, 255, 0.5)"
>
<div
style=
"margin-bottom:12px;"
>
<!-- <el-button type="primary" size="mini" @click="applyEditorChanges">保存修改</el-button> -->
<el-tooltip
:content=
"isAnyTaskRunning() ? '有任务进行中,完成后可操作' : ''"
:disabled=
"!isAnyTaskRunning()"
placement=
"top"
>
<span
class=
"disabled-tip"
>
<el-button
type=
"primary"
size=
"mini"
:disabled=
"isAnyTaskRunning()"
@
click=
"overwriteAndRegenerateLyrics"
>
覆盖上传并重新生成歌词
</el-button>
</span>
</el-tooltip>
<el-button
type=
"primary"
size=
"mini"
@
click=
"overwriteAndRegenerateLyrics"
>
覆盖上传并重新生成歌词
</el-button>
</div>
<div
v-if=
"subtitleEditor && subtitleEditor.mergedList"
>
<el-timeline>
...
...
form/index.js
View file @
18a303ab
...
...
@@ -28,13 +28,13 @@ document.addEventListener('DOMContentLoaded', () => {
window
.
courseware
.
getData
(
function
(
data
)
{
window
.
air
.
hideAirClassLoading
(
key
,
data
);
if
(
!
data
)
{
// 加载默认数据
$
.
getJSON
(
'
../play/defaultData.json
'
,
function
(
defaultData
)
{
initVueApp
(
defaultData
);
})
return
;
}
//
if (!data) {
//
// 加载默认数据
//
$.getJSON('../play/defaultData.json', function (defaultData) {
//
initVueApp(defaultData);
//
})
//
return;
//
}
//数据加载完,才算页面加载完成
initVueApp
(
data
);
},
key
);
...
...
@@ -1061,50 +1061,52 @@ function initVueApp(savedData) {
// 应用修改回原始数组
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
++
)
{
// 关键修复:根据 mergedList 重新构建数组,确保“拆分后的第二句”被真实写回
const
newSList
=
[];
const
newTList
=
[];
// 强制使用“数值秒 Number + 三位小数”的格式
const
sFmt
=
{
kind
:
'
number
'
,
unit
:
'
sec
'
,
decimals
:
3
};
const
tFmt
=
{
kind
:
'
number
'
,
unit
:
'
sec
'
,
decimals
:
3
};
for
(
let
i
=
0
;
i
<
merged
.
length
;
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
);
}
}
// 清空被合并项的原始内容,避免导出时出现重复
const
clearedS
=
Array
.
isArray
(
this
.
subtitleEditor
.
clearedSIndices
)
?
this
.
subtitleEditor
.
clearedSIndices
:
[];
const
clearedT
=
Array
.
isArray
(
this
.
subtitleEditor
.
clearedTIndices
)
?
this
.
subtitleEditor
.
clearedTIndices
:
[];
for
(
const
si
of
clearedS
)
{
if
(
sList
[
si
])
{
sList
[
si
].
words
=
[];
sList
[
si
].
text
=
''
;
}
}
for
(
const
ti
of
clearedT
)
{
if
(
tList
[
ti
])
{
tList
[
ti
].
translatedItems
=
[];
tList
[
ti
].
translated
=
''
;
}
// 构建字幕条目
const
sWords
=
Array
.
isArray
(
m
.
sWordsRef
)
?
m
.
sWordsRef
:
[];
// 计算 start/end(优先用分词的边界,否则回退到 m.start/m.end),以秒为内部统一单位
let
sStartSec
=
this
.
parseTimeToSeconds
(
sWords
.
length
?
sWords
[
0
].
start
:
m
.
start
);
let
sEndSec
=
this
.
parseTimeToSeconds
(
sWords
.
length
?
sWords
[
sWords
.
length
-
1
].
end
:
m
.
end
);
// 底限保护
if
(
isNaN
(
sStartSec
)
&&
!
isNaN
(
this
.
parseTimeToSeconds
(
m
.
start
)))
sStartSec
=
this
.
parseTimeToSeconds
(
m
.
start
);
if
(
isNaN
(
sEndSec
)
&&
!
isNaN
(
this
.
parseTimeToSeconds
(
m
.
end
)))
sEndSec
=
this
.
parseTimeToSeconds
(
m
.
end
);
// 写回时恢复为原始格式
const
sStartOut
=
this
.
serializeTimeByFormat
(
sStartSec
,
sFmt
);
const
sEndOut
=
this
.
serializeTimeByFormat
(
sEndSec
,
sFmt
);
newSList
.
push
({
start
:
sStartOut
,
end
:
sEndOut
,
words
:
sWords
,
text
:
this
.
buildSentenceFromWords
(
sWords
),
});
// 构建翻译条目
const
tSegs
=
Array
.
isArray
(
m
.
tItemsRef
)
?
m
.
tItemsRef
:
[];
// 翻译的 start/end 也尽量从分段计算(内部仍以秒为单位)
let
tStartSec
=
this
.
parseTimeToSeconds
(
tSegs
.
length
?
tSegs
[
0
].
start
:
sStartSec
);
let
tEndSec
=
this
.
parseTimeToSeconds
(
tSegs
.
length
?
tSegs
[
tSegs
.
length
-
1
].
end
:
sEndSec
);
const
tStartOut
=
this
.
serializeTimeByFormat
(
tStartSec
,
tFmt
);
const
tEndOut
=
this
.
serializeTimeByFormat
(
tEndSec
,
tFmt
);
newTList
.
push
({
start
:
tStartOut
,
end
:
tEndOut
,
translatedItems
:
tSegs
,
translated
:
this
.
buildSentenceFromTranslatedItems
(
tSegs
),
});
}
// 用新数组覆盖旧数组(确保下一步导出/覆盖时包含拆分后的完整结果)
this
.
subtitleEditor
.
subtitleList
=
newSList
;
this
.
subtitleEditor
.
translatedList
=
newTList
;
// 同步清空历史标记
this
.
subtitleEditor
.
clearedSIndices
=
[];
this
.
subtitleEditor
.
clearedTIndices
=
[];
this
.
$message
.
success
(
'
修改已应用到内存
'
);
},
...
...
@@ -1350,6 +1352,73 @@ function initVueApp(savedData) {
return
NaN
;
},
// 侦测原始时间格式(基于数组首个有效项的 start/end)
detectTimeFormatFromArray
(
arr
)
{
const
sample
=
(
arr
||
[]).
find
(
x
=>
x
&&
(
x
.
start
!=
null
||
x
.
end
!=
null
))
||
{};
const
v
=
sample
.
start
!=
null
?
sample
.
start
:
sample
.
end
;
if
(
v
==
null
)
{
// 默认使用字符串时码 MM:SS.mmm
return
{
kind
:
'
string
'
,
unit
:
'
clock
'
,
segments
:
2
,
decimals
:
3
,
decimalSeparator
:
'
.
'
,
};
}
if
(
typeof
v
===
'
number
'
)
{
if
(
Number
.
isInteger
(
v
)
&&
v
>=
1000
)
{
return
{
kind
:
'
number
'
,
unit
:
'
ms
'
};
}
// 秒(可能是小数)
const
decimals
=
(
v
.
toString
().
split
(
'
.
'
)[
1
]
||
''
).
length
;
return
{
kind
:
'
number
'
,
unit
:
'
sec
'
,
decimals
:
Math
.
min
(
Math
.
max
(
decimals
,
0
),
3
)
};
}
if
(
typeof
v
===
'
string
'
)
{
const
hasComma
=
v
.
indexOf
(
'
,
'
)
>=
0
;
const
decSep
=
hasComma
?
'
,
'
:
'
.
'
;
if
(
v
.
includes
(
'
:
'
))
{
const
segs
=
v
.
split
(
'
:
'
).
length
;
const
m
=
v
.
match
(
/
[
.,
](\d{1,3})\s
*$/
);
const
decimals
=
m
?
m
[
1
].
length
:
0
;
return
{
kind
:
'
string
'
,
unit
:
'
clock
'
,
segments
:
segs
>=
3
?
3
:
2
,
decimals
:
decimals
||
0
,
decimalSeparator
:
decSep
};
}
else
{
// 纯秒字符串
const
m
=
v
.
match
(
/
[
.,
](\d{1,3})\s
*$/
);
const
decimals
=
m
?
m
[
1
].
length
:
0
;
return
{
kind
:
'
string
'
,
unit
:
'
sec
'
,
decimals
:
decimals
||
0
,
decimalSeparator
:
decSep
};
}
}
// 兜底
return
{
kind
:
'
string
'
,
unit
:
'
clock
'
,
segments
:
2
,
decimals
:
3
,
decimalSeparator
:
'
.
'
,
};
},
// 将秒数序列化回原始格式
serializeTimeByFormat
(
seconds
,
fmt
)
{
if
(
seconds
==
null
||
isNaN
(
seconds
))
return
seconds
;
const
f
=
fmt
||
{
kind
:
'
string
'
,
unit
:
'
clock
'
,
segments
:
2
,
decimals
:
3
,
decimalSeparator
:
'
.
'
};
if
(
f
.
kind
===
'
number
'
)
{
if
(
f
.
unit
===
'
ms
'
)
return
Math
.
round
(
seconds
*
1000
);
const
dec
=
typeof
f
.
decimals
===
'
number
'
?
f
.
decimals
:
3
;
const
val
=
Number
(
seconds
.
toFixed
(
dec
));
return
val
;
}
// 字符串
const
decSep
=
f
.
decimalSeparator
||
'
.
'
;
const
decimals
=
typeof
f
.
decimals
===
'
number
'
?
f
.
decimals
:
3
;
const
pad
=
n
=>
n
.
toString
().
padStart
(
2
,
'
0
'
);
const
msInt
=
Math
.
round
((
seconds
%
1
)
*
Math
.
pow
(
10
,
decimals
));
if
(
f
.
unit
===
'
clock
'
)
{
let
s
=
Math
.
floor
(
seconds
);
const
h
=
Math
.
floor
(
s
/
3600
);
s
=
s
%
3600
;
const
m
=
Math
.
floor
(
s
/
60
);
const
sec
=
s
%
60
;
const
frac
=
decimals
>
0
?
(
decSep
+
msInt
.
toString
().
padStart
(
decimals
,
'
0
'
))
:
''
;
if
(
f
.
segments
===
3
||
h
>
0
)
{
return
`
${
pad
(
h
)}
:
${
pad
(
m
)}
:
${
pad
(
sec
)}${
frac
}
`
;
}
return
`
${
pad
(
m
)}
:
${
pad
(
sec
)}${
frac
}
`
;
}
// 纯秒字符串
const
intPart
=
Math
.
floor
(
seconds
);
const
frac
=
decimals
>
0
?
(
decSep
+
Math
.
round
((
seconds
-
intPart
)
*
Math
.
pow
(
10
,
decimals
)).
toString
().
padStart
(
decimals
,
'
0
'
))
:
''
;
return
`
${
intPart
}${
frac
}
`
;
},
// 工具:安全 JSON.stringify
safeStringify
(
obj
)
{
try
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment