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
62c3f146
Commit
62c3f146
authored
Jan 12, 2026
by
liujiangnan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat
parent
2a2fc7b2
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
681 additions
and
46 deletions
+681
-46
index.css
form/index.css
+4
-4
index.html
form/index.html
+97
-1
index.js
form/index.js
+560
-10
defaultData.json
play/defaultData.json
+20
-31
No files found.
form/index.css
View file @
62c3f146
...
@@ -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
;
...
...
form/index.html
View file @
62c3f146
...
@@ -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>
...
...
form/index.js
View file @
62c3f146
...
@@ -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
=
{
...
...
play/defaultData.json
View file @
62c3f146
{
{
"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
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